Thursday, 4 August 2016

Pathfinding part 2: the map editor

This post is a sort of follow up of the previous one. I thought I would show you how I created the simple level editor to layout the map I used for the path finding tutorial.

This will involve editor scripting and, although is very simple, is a good demonstration on how we can manipulate the Unity editor to improve workflow and create tools.

Obviously, every project is different and will require a different approach. For my particular case, I needed to create a tile map which could be made of 3 different types of tiles: grass, sand and wall. I didn't want to start from an empty map, so the way I did it was to create an initial map of a certain size of all grass tiles and then place the sand and wall tiles. That was my idea and that is what I'm going to show you.

By creating a map editor we can then create many more maps very quickly without having to place each tile prefab one by one, which can be very time consuming.

There are 2 main scripts for this project: one is the Map script, which is the one that featured the GeneratePath method we saw in the last post. This script will only be in charge of laying out the tiles and it's the script we will reference in the second script: MapEditor. This simply overrides the inspector for the Map script and is in charge of all the editor stuff.

Before I show you the scripts, I will display a picture of the inspector:

Fig 1

I highlighted to sections of the inspector with 2 different colors for a reason you will see shortly.

So, this is the MapEditor script:

 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
[CustomEditor(typeof(Map))]
public class MapEditor : Editor
{
 Ray r;
 RaycastHit hit;
 bool md = false;

 string[] tiles = { "Grass", "Sand", "Wall" };
 int ind = 0;

 public override void OnInspectorGUI()
 {
  Map map = (Map)target;
  DrawDefaultInspector ();

  if (GUILayout.Button ("Generate MAp")) {
   map.GenerateMap (); 
   //SerializedObject obj = new SerializedObject(map.ti
  }
   
  ind = EditorGUILayout.Popup ("Choose Tile",ind,tiles);
 }

 void OnSceneGUI()
 {

  int controlID = GUIUtility.GetControlID (FocusType.Passive);

  r = HandleUtility.GUIPointToWorldRay(Event.current.mousePosition);
 
  if (Physics.Raycast (r, out hit, 100, LayerMask.GetMask ("Tile"))) {
   
   
   if (Event.current.type == EventType.mouseDown) {
    GUIUtility.hotControl = controlID;
    md = true;

   }

   if(md)
    hit.collider.gameObject.GetComponent<Tile> ().ChangeType (ind);

  }

  if (Event.current.isMouse && Event.current.type == EventType.MouseUp) {
   GUIUtility.hotControl = 0;
   md = false;
  }


 }


}

Some of the code has been already explained in a previous post I made related to custom inspector. Line 1 and the inheritance from Editor on line 2 are necessary when creating a custom inspector.

Let's focus on the 2 methods in the script.

OnInspectorGui is overridden in order to draw a new inspector. Firstly, we get a reference to the script we want to redraw the inspector for, which is the Map script. The target variable is a built in variable that represents a reference to the object, which needs to be casted accordingly (line 13).

Then we draw the default inspector (line 14), which will display all the public variables that are highlighted in the yellow rectangle in Fig 1. And that's what DrawDefaultInspector() does, simply displays the inspector you would expect to see if you were not overriding it.

It is important to point out that, if you decide to override the drawing process with the method OnIspectorGui(), you MUST call the DrawDefaultInspector() function. If you omit that, this is what you will get:


You'll get nothing. Once you override that method, all the inspector drawing is handled to you.

Going back to the script, on line 16 I created a button which will be displayed in the inspector. By pressing the button, the GenerateMap() method in the Map script is called, which we are going to analyze next.

After that, on line 21 I created a pop up field which will let the user choose what tile to place. these 2 elements (button and pop up field) are visible in Fig 1 in the red rectangle.

The EditorGUILayout.Popup method will return an integer that represents the index of the item selected. As for the lists of selectable items, we pass an array of strings as parameter.

Before move to the next part, OnSceneGui(), let's quickly have a look at the GenerateMap() function in the Map script. I won't post the whole script as there are other things that are unrelated to our purpose, so let's see the method only.


 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
 public void GenerateMap()
    {
  tileParent = new GameObject("Tiles");
  tiles = new GameObject[mapWidth, mapHeight];
  tilescale = grassPrefab.transform.localScale.x;
  levelEditorMap = new int[mapWidth, mapHeight];

  for (int x = 0; x < mapWidth; x++)
  {
   for (int y = 0; y < mapHeight; y++)
   {
    levelEditorMap[x, y] = (int)Tiles.GRASS;
   }
  }

        GameObject g = null;

        for (int x = 0; x < mapWidth; x++)
        {
            for (int y = 0; y < mapHeight; y++)
            {

  currentPosition = new Vector3(startingPosition.x + (float)(x * (tilescale + 0.05f)) , startingPosition.y + (float)(y * (tilescale + 0.05f)), 0);
  g = Instantiate (grassPrefab, currentPosition, Quaternion.identity) as GameObject;
  g.GetComponent<Tile> ().type = (int)Tiles.GRASS;
  g.transform.SetParent(tileParent.transform);
                g.GetComponent<Tile>().arrayX = x;
                g.GetComponent<Tile>().arrayY= y;
                g.GetComponent<Tile>().mapReferece = this;
  tiles[x, y] = g;

              
            }
           
        }
  Debug.Log ("Length now is " + tiles.Length);
  GeneratePathGraph ();
    
    }

I initialize all the objects I need first, and I iterate through the 2 dimensional array levelEditorMap and initialize each element to a grass type tile. The tileParent object is just a parent object for all the tiles that we are going to spawn when generating the map. The other 2 dimensional array tiles will contain the actual gameobjects, the "physical" tiles we see in the scene, whereas levelEditorMap is the "logical" part. Honestly, there is really no need to separate the two, but that's how the path generation logic was set up previously so I left it as it is.

From line 18 to 35 is where the generation occurs. the position of each tile is represented by the Vector3 currentPosition. For both X and Y axis I added a little offset of 0.05 which will place a little space in between the tiles so they can be easily distinguished from each other. Other thatn that, all I do is to instantiate a grassPrefab (a simple quad colored green), assign some references to the Tile script of each instantiated grass prefab clone and that's it. Simple enough.

This is what you will see once you press the "Generate Map" button:


You can see the parent object highlighted in the red rectangle in the hierarchy and the grass tiles instantiated in the scene. Remember, the game is not running, this happened in the editor.

Now we can go back to the OnSceneGui method of the MapEditor script.

The hot control id is taken as it will become usefull when we are placing the tiles. Because we are placing objects by clicking we don't want to select the just placed object in the inspector, otherwise we will loose the focus that is now on the object containing the MapEditor script. That means that every time we place a tile we will need to re select that object in order to place another tile because we have now selected the in the inspector the tile we have just placed. I know, confusing, try it yourself by commenting out lines 27, 35 and 46, see what happens.

On line 29 we raycast from the camera. I know that raycasting is usually done differently (Camera.main.ScreenToPointRay...), but when we are doing a raycast in the editor, that is how you proceed. It took me a while to find out and I was glad when I did.

Then I simply check that the ray we are casting is hitting the tiles (line 31). If that's the case and we are clicking (line 34), we set the boolean variable md (mouse down) to true. When that variable is true, the tile that is being hit by the raycast will change type (I will show that script next) according to the index of the tile chosen with the pop up field we saw earlier in the inspector. the reason why I used a boolean variable is that so I can keep the mouse button down and drag the cursor around in order to change the type of tile as I go, without having to keep clicking for each tile.

Last pieace of code, when the mouse is released, we let go of the hot control (line 45 to 48).

To confuse you even more, check the ChangeType method in the Tile script:

 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
public void ChangeType(int t)
 {
  type = t;

  switch (t) {
  case (int)Map.Tiles.WALL:

   AssignColorToMaterial (this.gameObject, grey);
   originalColor = grey;
   movementCost = Mathf.Infinity;

   break;

  case (int)Map.Tiles.GRASS:
   AssignColorToMaterial (this.gameObject, green);
   originalColor = green;
   movementCost = 1;
   break;

  case (int)Map.Tiles.SAND:
   AssignColorToMaterial (this.gameObject, sand);
   originalColor = sand;
   movementCost = 15;
   break;
  }
 }

This method will change the type of the tile (color and movement cost) according to the integer passed. If you don;t know about movement cost and why it's there, please read the previous post about path finding.

One important thing to notice is how we assign the new color to the material. You would be tempted to simply get the renderer reference and change the material color as you normally would in a script, but you cannot do that in the editor. This will cause a material leaking error.

If you want to achieve this, you will need to do a trick. That trick is the AssignColotToMaterial function, which is displayed below:



1
2
3
4
5
6
void AssignColorToMaterial(GameObject g,Color c)
 {
  var tempMaterial = new Material(g.GetComponent<Renderer>().sharedMaterial);
  tempMaterial.color = c;
  g.GetComponent<Renderer>().sharedMaterial = tempMaterial;
 }

We basically create a temporary material, change its color and assign it to the gameobject. This step is necessary in order to to what we want.

That's all folks.

This is all probably very confusing and I was struggling myself to understand what is going on, however, as it always is very much the case in coding, is all about trying it yourself and getting your hands dirty.

I hope this post will help you getting started in editor scripting and creating an awesome custom level editor.

No comments:

Post a Comment