Tuesday 9 January 2018

Tilemap: custom brush for prefabs.

Finally, a new post.

I haven't been using Unity much recently as I got sucked into OpenGL development, however, with the recent updates that came to Unity I was drawn back to it.

Being a big fan of 2D, I was excited to see the new features added, in particular, the tilemap system: a very easy and fast way to lay out 2D maps and create levels.

I then started messing around with custom brushes and I came up with a slightly modified version of the prefab brush that is provided in this tutorial project. I modified the PrefabBrush script in order to be able to select different "prefab palettes" and display the preview in the scene editor.

Also, for this tutorial I re-used this asset you have seen before.

To set it up, I created 4 prefabs in 2 different folders "Collectibles" and "Enemies". I have a "Coin" and a "Heart" prefab in the "Collectibles" folder and a "Slime" and "Bat" prefab in the "Enemies" folder. These prefabs are going to be GameObjects that we will place in the scene using the brush we are creating. The brush will allow you to choose which prefab palette you wish to use and iterate through the GameObjects of that particular palette.

Firstly we need to create the template for the prefab palette: it does not get any easier that this script down here:


[CreateAssetMenu]
public class PrefabPalette : ScriptableObject {
 public GameObject[] prefabs;

}

I kid you not, that is all you need.

This script will enable you to create an asset, which is really a file in which you can place your prefabs:



You can now create 2 prefab palettes, one for the enemies, one for the collectables.

We can now take a look at the PrefabBrush script. I will highlight the modifications I made. It is important to notice how the script is placed inside a folder called "Editor", that is because it incorporates editor scripting as well and it must be inside that folder.


  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102


namespace UnityEditor
{
 [CreateAssetMenu]
 [CustomGridBrush(false, true, false, "Prefab Brush")]
 public class PrefabBrush : GridBrush
 {

  public PrefabPalette prefabPalette;

  [HideInInspector] public int index = 0;

  public override void Paint(GridLayout grid, GameObject brushTarget, Vector3Int position)
  {   

   GameObject prefab = prefabPalette.prefabs[index];
   GameObject instance = (GameObject) PrefabUtility.InstantiatePrefab(prefab);
   Undo.RegisterCreatedObjectUndo((Object)instance, "Paint Prefabs");
   if (instance != null)
   {
    instance.transform.SetParent(brushTarget.transform);
    instance.transform.position = grid.LocalToWorld(grid.CellToLocalInterpolated(new Vector3Int(position.x, position.y,0) + new Vector3(.5f, .5f, .5f)));
   
   }
  }

  public override void Erase(GridLayout grid, GameObject brushTarget, Vector3Int position)
  {
   // Do not allow editing palettes
   if (brushTarget.layer == 31)
    return;

   Transform erased = GetObjectInCell(grid, brushTarget.transform, new Vector3Int(position.x, position.y, position.z));
   if (erased != null)
    Undo.DestroyObjectImmediate(erased.gameObject);
  }

  private static Transform GetObjectInCell(GridLayout grid, Transform parent, Vector3Int position)
  {
   int childCount = parent.childCount;
   Vector3 min = grid.LocalToWorld(grid.CellToLocalInterpolated(position));
   Vector3 max = grid.LocalToWorld(grid.CellToLocalInterpolated(position + Vector3Int.one));
   Bounds bounds = new Bounds((max + min)*.5f, max - min);

   for (int i = 0; i < childCount; i++)
   {
    Transform child = parent.GetChild(i);
    if (bounds.Contains(child.position))
     return child;
   }
   return null;
  }
 
 }

 [CustomEditor(typeof(PrefabBrush))]
 public class PrefabBrushEditor : GridBrushEditor
 {
  private PrefabBrush prefabBrush { get { return target as PrefabBrush; } }
   
  GameObject holder;

  public override void OnMouseLeave ()
  {
   base.OnMouseLeave (); 

   if(holder)
    DestroyImmediate (holder);
  }

 
  public override void OnPaintSceneGUI (GridLayout gridLayout, GameObject brushTarget, BoundsInt position, GridBrushBase.Tool tool, bool executing)
  {

   base.OnPaintSceneGUI (gridLayout, null,position, tool, executing); 

   Event e = Event.current;

   if (e.type == EventType.KeyDown && e.keyCode == KeyCode.C) {
    //Debug.Log ("Click, index " + prefabBrush.index);

    prefabBrush.index++;
    if (prefabBrush.index == prefabBrush.prefabPalette.prefabs.Length)
     prefabBrush.index = 0;

    DestroyImmediate (holder);
    holder = Instantiate (prefabBrush.prefabPalette.prefabs [prefabBrush.index]);

   }

   if (!holder)
    holder = Instantiate (prefabBrush.prefabPalette.prefabs [prefabBrush.index]);

   holder.transform.position = position.position + new Vector3(.5f,0.5f,0.0f);

  }  

 }
}

Up to line 53 is the "normal" script, the actual PrefabBrush class. From line 53 onward is the custom editor script.

The PrefabBrush class is pretty much the same as it was in the original script. The method Pain is overridden in order to instantiate the currently selected prefab. This prefab is chosen according to an int value, which is the index of the array of GameObjects found in the PrefabPalette public object. The other 2 methods are straight from the original script and override erase functionality in order to delete the instantiated prefab rather than the underneath tiles that might be present in the tilemap.

The custom editor part is where I put my hands mostly. In order to show the "preview" of the prefab in the scene editor I used a trick: I instantiate an object of the selected prefab (the object holder in the script) and I constantly update its position. OnMouseLeave is called whenever the mouse leaves the editor window. In this method, I delete the holder gameobject so it is no longer present in the scene.

The method OnPaintSceneGUI is continuously called as long as the mouse is in the scene editor window. Here I do a couple of things: I check that the key "C" is pressed: if so, the value of index is incremented (and checked for boundaries), so the prefab will change. If that occurs, I delete the current holder and I create a new one with the new selected prefab from the prefab palette.

I also create a holder object anyway (line 90-91), as the "preview" of the GameObject must be present even if the key "C" is not pressed. I finally update the position of the holder object to follow the value of position which is the cell in the tilemap the mouse is currently hovering on.

At this point, we simply need to create a Prefab Brush object in the project folder and it will then be available in the Tile Palette window:




We can then select the prefab palette we created and start placing prefabs!






Conclusion

The new tilemap feature is fantastic if you are planning to create a 2D game and quickly need to draft levels.

It is important to remember:

- Always separate different tiles/prefabs in different tilemaps: just like in the example above, enemies prefabs are placed in a different tilamap than collectibles.

- Make sure to select the actual tilemap you wish to draw on in the Tile Palette window:


It's easy to forget that and place all the tiles/prefabs in one single tilemap.

- Check the tool you are using: occasionally, the "erase" tool remains selected and it's easy to get confused on "why is it not painting??". Silly mistakes, but can happen.