Creating Tower Defense in Unity, Part 1

Original author: Jasper Flick
  • Transfer

Field


  • Creating a tile field.
  • Search paths using breadth-first search.
  • Implement support for empty and end tiles, as well as wall tiles.
  • Editing content in game mode.
  • Optional display of grid fields and paths.

This is the first part of a series of tutorials on creating a simple tower defense game . In this part, we will consider creating a playing field, finding a path, and placing final tiles and walls.

The tutorial was created in Unity 2018.3.0f2.


A field ready for use in a tower defense genre tile game.

Tower Defense game


Tower defense is a genre in which the player’s goal is to destroy crowds of enemies until they reach their final point. The player fulfills his goal by building towers that attack enemies. This genre has a lot of variations. We will create a game with a tile field. Enemies will move across the field towards their end point, and the player will create obstacles for them.

I will assume that you have already studied a series of tutorials on managing objects .

Field


The playing field is the most important part of the game, so we will create it first. This will be a game object with its own component GameBoard, which can be initialized by setting the size in two dimensions, for which we can use the value Vector2Int. The field should work with any size, but we will choose the size somewhere else, so we will create a common method for this Initialize.

In addition, we visualize the field with one quadrangle, which will denote the earth. We will not make the field object itself a quadrilateral, but add a child quad object to it. Upon initialization, we will make the XY scale of the earth equal to the size of the field. That is, each tile will have a size of one square unit of measure for the engine.

using UnityEngine;
	public class GameBoard : MonoBehaviour {
	[SerializeField]
	Transform ground = default;
	Vector2Int size;
	public void Initialize (Vector2Int size) {
	this.size = size;
	ground.localScale = new Vector3(size.x, size.y, 1f);
	}
	}

Why explicitly set ground to the default value?
The idea is that everything customizable through the Unity editor is accessible through serialized hidden fields. It is necessary that these fields can only be changed in the inspector. Unfortunately, the Unity editor will constantly display a compiler warning that the value is never assigned. We can suppress this warning by explicitly setting the default value for the field. You can assign and null, but I did so to explicitly show that we simply use the default value, which is not a true reference to ground, so we apply it default.

Create a field object in a new scene and add a child quad with a material that looks like earth. Since we are creating a simple prototype game, a uniform green material will be sufficient. Rotate quad 90 ° along the X axis so that it lies on the XZ plane.




Playing field.

Why not position the game on the XY plane?
Although the game will take place in 2D space, we will render it in 3D, with 3D enemies and a camera that can be moved relative to a certain point. The XZ plane is more convenient for this and corresponds to the standard skybox orientation used for ambient lighting.

The game


Next, create a component Gamethat will be responsible for the entire game. At this stage, this will mean that it is initializing the field. We just make the size customizable through the inspector and force the component to initialize the field when it wakes up. Let's use the default size of 11 × 11.

using UnityEngine;
	public class Game : MonoBehaviour {
	[SerializeField]
	Vector2Int boardSize = new Vector2Int(11, 11);
	[SerializeField]
	GameBoard board = default;
	void Awake () {
	board.Initialize(boardSize);
	}
	}

Field sizes can only be positive and it makes little sense to create a field with a single tile. So let's limit the minimum to 2 × 2. This can be done by adding a method OnValidateto force the minimum values ​​to be limited.

 void OnValidate () {
	if (boardSize.x < 2) {
	boardSize.x = 2;
	}
	if (boardSize.y < 2) {
	boardSize.y = 2;
	}
	}

When is Onvalidate called?
If it exists, the Unity editor calls it for the components after changing them. Including when adding them to the game object, after loading the scene, after recompiling, after changing in the editor, after canceling / retrying, and after resetting the component.

OnValidate- This is the only place in the code where the assignment of values ​​to the component configuration fields is allowed.


Game object.

Now, when you start the game mode, we will receive a field with the correct size. During the game, position the camera so that the entire board is visible, copy its transformation component, exit the play mode and paste the values ​​of the component. In the case of an 11 × 11 field at the origin, to get a convenient view from above, you can position the camera in position (0.10.0) and rotate it 90 ° along the X axis. We will leave the camera in this fixed position, but it’s possible change it in the future.


Camera over the field.

How to copy and paste component values?
Through the drop-down menu that appears when you click on the button with the gear in the upper right corner of the component.

Prefab tile


The field consists of square tiles. Enemies will be able to move from tile to tile, crossing the edges, but not diagonally. Movement will always occur towards the nearest end point. Let's graphically denote the direction of movement along the tile with an arrow. You can download the arrow texture here .


Arrow on a black background.

Place the arrow texture in your project and enable the Alpha As Transparency option . Then create a material for the arrow, which can be the default material for which cutout mode is selected, and select the arrow as the main texture.


Arrow material.

Why use cutout render mode?
It allows you to obscure the arrow using the standard Unity rendering pipeline.

To denote each tile in the game, we will use the game object. Each of them will have its own quad with arrow material, just as the field has a quad of earth. We will also add component tiles GameTilewith a link to their arrow.

using UnityEngine;
	public class GameTile : MonoBehaviour {
	[SerializeField]
	Transform arrow = default;
	}

Create a tile object and turn it into a prefab. The tiles will be flush with the ground, so raise the arrow up a bit to avoid problems with depth when rendering. Also zoom out a little, so that there is little space between adjacent arrows. A Y offset of 0.001 and a scale of 0.8 that is the same for all axes will do.




Prefab tile.

Where is the prefab tile hierarchy?
You can open the prefab editing mode by double-clicking on the prefab asset, or by selecting the prefab and clicking on the Open Prefab button in the inspector. You can exit the prefab editing mode by clicking on the button with an arrow in the upper left corner of its hierarchy header.

Note that the tiles themselves do not have to be game objects. They are only needed in order to track the state of the field. We could use the same approach as for behavior in the Object Management series of tutorials . But in the early stages of simple games or prototypes of game objects, we are quite happy. This can be changed in the future.

We have tiles


To create tiles, you GameBoardmust have a link to the tile prefab.

 [SerializeField]
	GameTile tilePrefab = default;


Link to the prefab tile.

He can then create his instances using a double loop over two grid dimensions. Although the size is expressed as X and Y, we will arrange the tiles on the XZ plane, as well as the field itself. Since the field is centered relative to the origin, we need to subtract the corresponding size minus one divided by two from the components of the tile position. Please note that this must be a floating point division, otherwise it will not work for even sizes.

	public void Initialize (Vector2Int size) {
		this.size = size;
		ground.localScale = new Vector3(size.x, size.y, 1f);
		Vector2 offset = new Vector2(
			(size.x - 1) * 0.5f, (size.y - 1) * 0.5f
		);
		for (int y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++) {
				GameTile tile = Instantiate(tilePrefab);
				tile.transform.SetParent(transform, false);
				tile.transform.localPosition = new Vector3(
					x - offset.x, 0f, y - offset.y
				);
			}
		}
	}


Created instances of tiles.

Later we will need access to these tiles, so we will track them in an array. We do not need a list, because after initialization, the size of the field will not change.

	GameTile[] tiles;
	public void Initialize (Vector2Int size) {
		…
		tiles = new GameTile[size.x * size.y];
		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				GameTile tile = tiles[i] = Instantiate(tilePrefab);
				…
			}
		}
	}

How does this assignment work?
This is a linked assignment. In this case, this means that we are assigning a link to the tile instance to both the array element and the local variable. These operations perform the same as the code shown below.

GameTile t = Instantiate(tilePrefab);
tiles[i] = t;
GameTile tile = t;

Search for a way


At this stage, each tile has an arrow, but they all point in the positive direction of the Z axis, which we will interpret as north. The next step is to determine the correct direction for the tile. We do this by finding the path that the enemies must follow to the end point.

Tile Neighbors


The paths go from tile to tile, in the north, east, south or west. To simplify the search, we make GameTiletrack links to its four neighbors.

	GameTile north, east, south, west;

Relations between neighbors are symmetrical. If the tile is the eastern neighbor of the second tile, then the second is the western neighbor of the first. Add to the GameTilegeneral static method to determine this relationship between two tiles.

	public static void MakeEastWestNeighbors (GameTile east, GameTile west) {
		west.east = east;
		east.west = west;
	}

Why use a static method?
We can make it an instance method with a single parameter, and in this case we will call it as eastTile.MakeEastWestNeighbors(westTile)or something similar. But in cases where it is not clear which of the tiles the method should be called on, it is better to use static methods. Examples are methods Distanceand Dotclass Vector3.

Once connected, it should never change. If this happens, then we made a mistake in the code. You can verify this by comparing both links before assigning values ​​to null, and displaying an error in the console if this is incorrect. You can use the method for this Debug.Assert.

	public static void MakeEastWestNeighbors (GameTile east, GameTile west) {
		Debug.Assert(
			west.east == null && east.west == null, "Redefined neighbors!"
		);
		west.east = east;
		east.west = west;
	}

What does Debug.Assert do?
If the first argument is equal false, then it displays a condition error, using the second argument if it is specified. Such a call is included only in test builds, but not in release builds. Therefore, this is a good way to add checks during the development process that will not affect the final release.

Add a similar method to create relations between the northern and southern neighbors.

	public static void MakeNorthSouthNeighbors (GameTile north, GameTile south) {
		Debug.Assert(
			south.north == null && north.south == null, "Redefined neighbors!"
		);
		south.north = north;
		north.south = south;
	}

We can establish this relationship when creating tiles in GameBoard.Initialize. If the X coordinate is greater than zero, then we can create an east-west relationship between the current and previous tiles. If the Y coordinate is greater than zero, then we can create a north-south relationship between the current tile and the tile from the previous line.

		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…
				if (x > 0) {
					GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]);
				}
				if (y > 0) {
					GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]);
				}
			}
		}

Note that the tiles on the edges of the field do not have four neighbors. One or two references to neighbors will remain equal null.

Distance and direction


We will not force all enemies to constantly search for the way. This needs to be done only once per tile. Then the enemies will be able to request from the tile in which they are located where to move on. We will store this information in GameTileby adding a link to the next path tile. In addition, we will also save the distance to the endpoint, expressed as the number of tiles that must be visited before the enemy reaches the endpoint. For enemies, this information is useless, but we will use it to find the shortest paths.

	GameTile north, east, south, west, nextOnPath;
	int distance;

Each time we decide that we need to look for paths, we will need to initialize the path data. Until the path is found, there is no next tile and the distance can be considered infinite. We can imagine this as the maximum possible integer value int.MaxValue. Add a generic method ClearPathto reset GameTileto this state.

	public void ClearPath () {
		distance = int.MaxValue;
		nextOnPath = null;
	}

Paths can only be searched if we have an endpoint. This means that the tile must become the endpoint. Such a tile has a distance of zero, and it does not have the last tile, because the path ends on it. Add a generic method that turns a tile into an endpoint.

	public void BecomeDestination () {
		distance = 0;
		nextOnPath = null;
	}

Ultimately, all the tiles should turn into a path, so their distance will no longer be equal int.MaxValue. Add a convenient getter property to check if the tile currently has a path.

	public bool HasPath => distance != int.MaxValue;

Как работает это свойство?
Это укороченная запись задания свойства-геттера, содержащего только одно выражение. Она делает то же самое, что и показанный ниже код.

	public bool HasPath {
		get {
			return distance != int.MaxValue;
		}
	}

Оператор-стрелку => также можно использовать по отдельности для геттера и сеттера свойств, для тел методов, конструкторов и в некоторых других местах.

Выращиваем путь


If we have a tile with a path, then we can let it grow a path towards one of its neighbors. Initially, the only tile with the path is the end point, so we start from zero distance and increase it from here, moving in the opposite direction to the movement of the enemies. That is, all immediate neighbors of the endpoint will have a distance of 1, and all neighbors of these tiles will have a distance of 2, and so on.

Add a GameTilehidden method to grow the path to one of its neighbors, specified through the parameter. The distance to the neighbor is one more than the current tile, and the neighbor’s path indicates the current tile. This method should only be called for tiles that already have a path, so let's check this with assert.

	void GrowPathTo (GameTile neighbor) {
		Debug.Assert(HasPath, "No path!");
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
	}

The idea is that we call this method once for each of the four neighbors of the tile. Since some of these links will be equal null, we will check this and stop execution, if so. In addition, if a neighbor already has a path, then we should not do anything and also stop doing it.

	void GrowPathTo (GameTile neighbor) {
		Debug.Assert(HasPath, "No path!");
		if (neighbor == null || neighbor.HasPath) {
			return;
		}
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
	}

How it GameTiletracks its neighbors is unknown to the rest of the code. Therefore it GrowPathTois hidden. We will add general methods ordering the tile to grow its path in a certain direction, indirectly calling GrowPathTo. But the code that searches throughout the field should keep track of which tiles were visited. Therefore, we will make sure that he returns the neighbor or null, if the execution is terminated.

	GameTile GrowPathTo (GameTile neighbor) {
		if (!HasPath || neighbor == null || neighbor.HasPath) {
			return null;
		}
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
		return neighbor;
	}

Now add methods for growing paths in specific directions.

	public GameTile GrowPathNorth () => GrowPathTo(north);
	public GameTile GrowPathEast () => GrowPathTo(east);
	public GameTile GrowPathSouth () => GrowPathTo(south);
	public GameTile GrowPathWest () => GrowPathTo(west);

Wide search


Ensure that all tiles contain the correct path data should GameBoard. We do this by performing a breadth-first search. Let's start with the endpoint tile, and then grow the path to its neighbors, then to the neighbors of these tiles, and so on. With each step, the distance increases by one, and the paths never grow in the direction of tiles that already have paths. This ensures that all tiles as a result will point along the shortest path to the endpoint.

What about finding a path using A *?
The A * algorithm is the evolutionary development of breadth-first search. It is useful when we are looking for the only shortest path. But we need all the shortest paths, so A * does not give any advantages. For examples of breadth-first search and A * on a grid of hexagons with animation, see the series of tutorials about maps of hexagons .

To perform the search, we need to track the tiles that we added to the path, but from which we have not yet grown the path. This collection of tiles is often called the search frontier. It is important that the tiles are processed in the same order in which they are added to the border, so let's use the queue Queue. Later we will have to perform the search several times, so let's set it as a field GameBoard.

using UnityEngine;
using System.Collections.Generic;
public class GameBoard : MonoBehaviour {
	…
	Queue searchFrontier = new Queue();
	…
}

In order for the state of the playing field to always be true, we must find the paths at the end Initialize, but put the code in a separate method FindPaths. First of all, you need to clear the path of all the tiles, then make one tile the end point and add it to the border. Let's first select the first tile. Since it tilesis an array, we can use a loop foreachwithout fear of memory pollution. If we later move from an array to a list, we will also need to replace the loops with foreachloops for.

	public void Initialize (Vector2Int size) {
		…
		FindPaths();
	}
	void FindPaths () {
		foreach (GameTile tile in tiles) {
			tile.ClearPath();
		}
		tiles[0].BecomeDestination();
		searchFrontier.Enqueue(tiles[0]);
	}

Next, we need to take one tile from the border and grow a path to all its neighbors, adding them all to the border. First we’ll move north, then east, south and finally west.

	public void FindPaths () {
		foreach (GameTile tile in tiles) {
			tile.ClearPath();
		}
		tiles[0].BecomeDestination();
		searchFrontier.Enqueue(tiles[0]);
		GameTile tile = searchFrontier.Dequeue();
		searchFrontier.Enqueue(tile.GrowPathNorth());
		searchFrontier.Enqueue(tile.GrowPathEast());
		searchFrontier.Enqueue(tile.GrowPathSouth());
		searchFrontier.Enqueue(tile.GrowPathWest());
	}

We repeat this stage, while there are tiles in the border.

		while (searchFrontier.Count > 0) {
			GameTile tile = searchFrontier.Dequeue();
			searchFrontier.Enqueue(tile.GrowPathNorth());
			searchFrontier.Enqueue(tile.GrowPathEast());
			searchFrontier.Enqueue(tile.GrowPathSouth());
			searchFrontier.Enqueue(tile.GrowPathWest());
		}

Growing a path does not always lead us to a new tile. Before adding to the queue, we need to check the value on null, but we can postpone the check nulluntil until after the output from the queue.

			GameTile tile = searchFrontier.Dequeue();
			if (tile != null) {
				searchFrontier.Enqueue(tile.GrowPathNorth());
				searchFrontier.Enqueue(tile.GrowPathEast());
				searchFrontier.Enqueue(tile.GrowPathSouth());
				searchFrontier.Enqueue(tile.GrowPathWest());
			}

Display the paths


Now we have a field containing the correct paths, but so far we do not see this. You need to configure the arrows so that they point along the path through their tiles. This can be done by turning them. Since these turns are always the same, we add in GameTileone static field Quaternionfor each of the directions.

	static Quaternion
		northRotation = Quaternion.Euler(90f, 0f, 0f),
		eastRotation = Quaternion.Euler(90f, 90f, 0f),
		southRotation = Quaternion.Euler(90f, 180f, 0f),
		westRotation = Quaternion.Euler(90f, 270f, 0f);

Also add a generic method ShowPath. If the distance is zero, then the tile is the end point and there is nothing to point to, so deactivate its arrow. Otherwise, activate the arrow and set its rotation. The desired direction can be determined by comparing nextOnPathwith its neighbors.

	public void ShowPath () {
		if (distance == 0) {
			arrow.gameObject.SetActive(false);
			return;
		}
		arrow.gameObject.SetActive(true);
		arrow.localRotation =
			nextOnPath == north ? northRotation :
			nextOnPath == east ? eastRotation :
			nextOnPath == south ? southRotation :
			westRotation;
	}

Call this method for all tiles at the end GameBoard.FindPaths.

	public void FindPaths () {
		…
		foreach (GameTile tile in tiles) {
			tile.ShowPath();
		}
	}


Found ways.

Why don't we turn the arrow directly into GrowPathTo?
To separate the logic and visualization of the search. Later we will make the visualization disabled. If the arrows do not appear, we do not need to rotate them each time we call FindPaths.

Change search priority


It turns out that when the end point is the southwest corner, all the paths go exactly west until they reach the edge of the field, after which they turn south. Everything is true here, because there are really no shorter paths to the end point, because diagonal movements are impossible. However, there are many other shortest paths that may look prettier.

To better understand why such paths are found, move the end point to the center of the map. With an odd field size, it's just a tile in the middle of the array.

		tiles[tiles.Length / 2].BecomeDestination();
		searchFrontier.Enqueue(tiles[tiles.Length / 2]);


End point in the center.

The result seems logical if you remember how the search works. Since we add neighbors in the north-east-south-west order, the north has the highest priority. Since we are doing the search in reverse order, this means that the last direction we have traveled is south. That is why only a few arrows point to the south and many point to the east.

You can change the result by setting the priorities of the directions. Let's swap east and south. So we have to get the north-south and east-west symmetry.

				searchFrontier.Enqueue(tile.GrowPathNorth());
				searchFrontier.Enqueue(tile.GrowPathSouth());
				searchFrontier.Enqueue(tile.GrowPathEast());
				searchFrontier.Enqueue(tile.GrowPathWest())


The search order is north-south-east-west.

It looks prettier, but it is better for the paths to change direction, approaching diagonal movement where it will look natural. We can do this by reversing the search priorities of neighboring tiles in a checkerboard pattern.

Instead of figuring out what type of tile we are processing during the search, we add to the GameTilegeneral property that indicates whether the current tile is an alternative.

	public bool IsAlternative { get; set; }

We will set this property in GameBoard.Initialize. First, mark the tiles as alternative if their X coordinate is even.

		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…
				tile.IsAlternative = (x & 1) == 0;
			}
		}

What does the operation (x & 1) == 0 do?
A single ampersand is a binary AND operator. It performs a logical AND operation for each individual pair of bits of its operands. Therefore, in order for the final bit to be equal to 1, both bits of the pair must be equal to 1. For example, 10101010 and 00001111 give us 00001010.

In computers, numbers are stored in binary form. Only 0 and 1 can be used in them. In binary form, the sequence 1, 2, 3, 4 is written as 1, 10, 11, 100. As you can see, the least significant bit of even numbers is zero.

We use binary AND as a mask, ignoring everything except the least significant bit. If the result is zero, then we are dealing with an even number.

Secondly, we change the sign of the result if their Y coordinate is even. So we will create a chess pattern.

				tile.IsAlternative = (x & 1) == 0;
				if ((y & 1) == 0) {
					tile.IsAlternative = !tile.IsAlternative;
				}

As FindPathswe keep the same order as the search for alternative tile, but to make it back to all other tiles. This will force the path to diagonal movement and create zigzags.

			if (tile != null) {
				if (tile.IsAlternative) {
					searchFrontier.Enqueue(tile.GrowPathNorth());
					searchFrontier.Enqueue(tile.GrowPathSouth());
					searchFrontier.Enqueue(tile.GrowPathEast());
					searchFrontier.Enqueue(tile.GrowPathWest());
				}
				else {
					searchFrontier.Enqueue(tile.GrowPathWest());
					searchFrontier.Enqueue(tile.GrowPathEast());
					searchFrontier.Enqueue(tile.GrowPathSouth());
					searchFrontier.Enqueue(tile.GrowPathNorth());
				}
			}


Variable search order.

Changing Tiles


At this point, all tiles are empty. One tile is used as an endpoint, but apart from the lack of a visible arrow, it looks the same as everyone else. We will add the ability to change tiles by placing objects on them.

Tile Content


Tile objects themselves are simply a way to track tile information. We do not modify these objects directly. Instead, add separate content and place it on the field. For now, we can distinguish between empty tiles and endpoint tiles. To indicate these cases, create an enumeration GameTileContentType.

public enum GameTileContentType {
	Empty, Destination
}

Next, create a component type GameTileContentthat allows you to set the type of its contents through the inspector, and access to it will be through a common getter property.

using UnityEngine;
public class GameTileContent : MonoBehaviour {
	[SerializeField]
	GameTileContentType type = default;
	public GameTileContentType Type => type;
}

Then we will create prefabs for two types of content, each of which has a component GameTileContentwith the corresponding specified type. Let's use a blue flattened cube to designate endpoint tiles. Since it is almost flat, he does not need a collider. To prefab empty content, use an empty game object.

destination

empty

Prefabs of the endpoint and empty content.

We will give the content object to the empty tiles, because then all the tiles will always have the content, which means we will not need to check the links to the contents for equality null.

Content Factory


To make the content editable, we will also create a factory for this, using the same approach as in the Object Management tutorial . This means that you GameTileContentmust keep track of your original factory, which should be set only once, and send yourself back to the factory in the method Recycle.

	GameTileContentFactory originFactory;
	…
	public GameTileContentFactory OriginFactory {
		get => originFactory;
		set {
			Debug.Assert(originFactory == null, "Redefined origin factory!");
			originFactory = value;
		}
	}
	public void Recycle () {
		originFactory.Reclaim(this);
	}

This assumes existence GameTileContentFactory, therefore, we will create a scriptable object type for this with the required method Recycle. At this stage, we will not bother with the creation of a fully functional factory that utilizes the contents, so we will make it simply destroy the contents. Later it will be possible to add reuse of objects to the factory without changing the rest of the code.

using UnityEngine;
using UnityEngine.SceneManagement;
[CreateAssetMenu]
public class GameTileContentFactory : ScriptableObject {
	public void Reclaim (GameTileContent content) {
		Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!");
		Destroy(content.gameObject);
	}
}

Add a hidden method Getto the factory with a prefab as a parameter. Here we again skip the reuse of objects. He creates an instance of the object, sets its original factory, moves it to the factory scene and returns it.

	GameTileContent Get (GameTileContent prefab) {
		GameTileContent instance = Instantiate(prefab);
		instance.OriginFactory = this;
		MoveToFactoryScene(instance.gameObject);
		return instance;
	}

The instance has been moved to the factory content scene, which can be created as needed. If we are in the editor, then before creating a scene, we need to check if it exists, in case we lose sight of it during a hot restart.

	Scene contentScene;
	…
	void MoveToFactoryScene (GameObject o) {
		if (!contentScene.isLoaded) {
			if (Application.isEditor) {
				contentScene = SceneManager.GetSceneByName(name);
				if (!contentScene.isLoaded) {
					contentScene = SceneManager.CreateScene(name);
				}
			}
			else {
				contentScene = SceneManager.CreateScene(name);
			}
		}
		SceneManager.MoveGameObjectToScene(o, contentScene);
	}

We only have two types of content, so just add two prefab configuration fields for them.

	[SerializeField]
	GameTileContent destinationPrefab = default;
	[SerializeField]
	GameTileContent emptyPrefab = default;

The last thing that needs to be done for the factory to work is to create a general method Getwith a parameter GameTileContentTypethat receives an instance of the corresponding prefab.

	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			case GameTileContentType.Destination: return Get(destinationPrefab);
			case GameTileContentType.Empty: return Get(emptyPrefab);
		}
		Debug.Assert(false, "Unsupported type: " + type);
		return null;
	}

Is it mandatory to add a separate instance of empty content to each tile?
Since empty content does not display graphically, we can get by creating an instance of the empty content object and reusing it for all tiles. But at this stage, we do not have to deal with such optimizations. In addition, you can still add some kind of visualization to empty tiles, for example, stones, grass, flowers and so on. You can even mix different visualizations, forcing the factory to return a random item each time. While we do not need this, but in the future we can make this change simply by changing the factory.

Let's create a factory asset and configure its links to prefabs.


Content Factory

And then pass the Gamelink to the factory.

	[SerializeField]
	GameTileContentFactory tileContentFactory = default;


Game with a factory.

Tapping a tile


To change the field, we need to be able to select a tile. We will make it possible in game mode. We will emit a beam into the scene in the place where the player clicked on the game window. If the beam intersects with the tile, then the player touched it, that is, it must be changed. Gamewill handle the player’s input, but will be responsible for determining which tile the player touched GameBoard.

Not all rays intersect with the tile, so sometimes we will not receive anything. Therefore, we add to the GameBoardmethod GetTile, which always always returns initially null(this means that the tile was not found).

	public GameTile GetTile (Ray ray) {
		return null;
	}

To determine if a ray has crossed a tile, we need to call Physics.Raycastby specifying the ray as an argument. It returns information about whether there was an intersection. If so, then we can return the tile, although we don’t know which one yet, so for now we’ll return it null.

	public GameTile TryGetTile (Ray ray) {
		if (Physics.Raycast(ray) {
			return null;
		}
		return null;
	}

To find out if there was an intersection with a tile, we need more information about the intersection. Physics.Raycastcan provide this information using the second parameter RaycastHit. This is the output parameter, which is indicated by the word outin front of it. This means that a method call can assign a value to the variable that we pass to it.

		RaycastHit hit;
		if (Physics.Raycast(ray, out hit) {
			return null;
		}

We can embed the declaration of the variables used for the output parameters, so let's do it.

		if (Physics.Raycast(ray, out RaycastHit hit) {
			return null;
		}

We don’t care with which collider the intersection occurred, we just use the XZ intersection position to determine the tile. We obtain the coordinates of the tile by adding half the size of the field to the coordinates of the intersection point, and then converting the results to integer values. The final tile index as a result will be its X coordinate plus the Y coordinate multiplied by the field width.

		if (Physics.Raycast(ray, out RaycastHit hit)) {
			int x = (int)(hit.point.x + size.x * 0.5f);
			int y = (int)(hit.point.z + size.y * 0.5f);
			return tiles[x + y * size.x];
		}

But this is only possible when the coordinates of the tile are within the field, so we will check this. If this is not the case, then the tile will not be returned.

			int x = (int)(hit.point.x + size.x * 0.5f);
			int y = (int)(hit.point.z + size.y * 0.5f);
			if (x >= 0 && x < size.x && y >= 0 && y < size.y) {
				return tiles[x + y * size.x];
			}

Content change


So that you can change the contents of the tile, add to the GameTilegeneral property Content. Its getter simply returns the contents, and the setter discards the previous contents, if any, and places the new contents.

	GameTileContent content;
	public GameTileContent Content {
		get => content;
		set {
			if (content != null) {
				content.Recycle();
			}
			content = value;
			content.transform.localPosition = transform.localPosition;
		}
	}

This is the only place you need to check the content on null, because initially we have no content. To guarantee, we execute assert so that the setter is not called with null.

		set {
			Debug.Assert(value != null, "Null assigned to content!");
			…
		}

And finally, we need a player input. Converting a mouse click into a ray can be done by calling ScreenPointToRaywith Input.mousePositionas an argument. The call must be made for the main camera, which can be accessed through Camera.main. Add property c for this Game.

		Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition);

Then we add a method Updatethat checks whether the main mouse button was pressed during the upgrade. To do this, call Input.GetMouseButtonDownwith zero as an argument. If the key has been pressed, we process the player’s touch, that is, we take the tile from the field and set the endpoint as its contents, taking it from the factory.

	void Update () {
		if (Input.GetMouseButtonDown(0)) {
			HandleTouch();
		}
	}
	void HandleTouch () {
		GameTile tile = GetTile(TouchRay);
		if (tile != null) {
			tile.Content =
				tileContentFactory.Get(GameTileContentType.Destination);
		}
	}

Now we can turn any tile into an endpoint by pressing the cursor.


Several endpoints.

Making the field right


Although we can turn tiles into endpoints, this does not affect the paths so far. In addition, we have not yet set empty content for tiles. Maintaining the correctness and integrity of the field is a task GameBoard, so let's give him the responsibility of setting the contents of the tile. To implement this, we will give it a link to the content factory through its method Intialize, and use it to give all the tiles an instance of empty content.

	GameTileContentFactory contentFactory;
	public void Initialize (
		Vector2Int size, GameTileContentFactory contentFactory
	) {
		this.size = size;
		this.contentFactory = contentFactory;
		ground.localScale = new Vector3(size.x, size.y, 1f);
		tiles = new GameTile[size.x * size.y];
		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
			}
		}
		FindPaths();
	}

Now I Gamehave to transfer my factory to the field.

	void Awake () {
		board.Initialize(boardSize, tileContentFactory);
	}

Why not add a factory configuration field to the GameBoard?
Paul needs a factory, but he doesn't need to know where it comes from. In the future, we may have several factories used to change the appearance of the contents of the field.

Since we now have several endpoints, we change it GameBoard.FindPathsso that it calls BecomeDestinationfor each and adds them all to the border. And that’s all it takes to support multiple endpoints. All other tiles are cleared as usual. Then we delete the hard-set endpoint in the center.

	void FindPaths () {
		foreach (GameTile tile in tiles) {
			if (tile.Content.Type == GameTileContentType.Destination) {
				tile.BecomeDestination();
				searchFrontier.Enqueue(tile);
			}
			else {
				tile.ClearPath();
			}
		}
		//tiles[tiles.Length / 2].BecomeDestination();
		//searchFrontier.Enqueue(tiles[tiles.Length / 2]);
		…
	}

But if we can turn tiles into endpoints, then we should be able to perform the reverse operation, turn endpoints into empty tiles. But then we can get a field with no end points at all. In this case, FindPathswill not be able to perform its task. This happens when the border is empty after the path initialization for all cells. We denote this as an invalid state of the field, returning falseand completing execution; otherwise return at the end true.

	bool FindPaths () {
		foreach (GameTile tile in tiles) {
			…
		}
		if (searchFrontier.Count == 0) {
			return false;
		}
		…
		return true;
	}

The easiest way to implement support for removing endpoints, making it a switch operation. By clicking on the empty tiles, we will turn them into endpoints, and by clicking on the endpoints, we will delete them. But now it’s engaged in changing the content GameBoard, so we’ll give it a general method ToggleDestinationwhose parameter is the tile. If the tile is the endpoint, then make it empty and call FindPaths. Otherwise, we make it the end point and also call it FindPaths.

	public void ToggleDestination (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Destination) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else {
			tile.Content = contentFactory.Get(GameTileContentType.Destination);
			FindPaths();
		}
	}

Adding an endpoint can never create an invalid field state, and deleting an endpoint can. Therefore, we will check whether it succeeded in successfully executing FindPathsafter we made the tile empty. If not, then cancel the change, turning the tile back to the endpoint, and call again FindPathsto return to the previous correct state.

		if (tile.Content.Type == GameTileContentType.Destination) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			if (!FindPaths()) {
				tile.Content =
					contentFactory.Get(GameTileContentType.Destination);
				FindPaths();
			}
		}

Can validation be made more efficient?
Using an additional field, you can track how many endpoints are on the field. However, this is important only when the player tries to remove the last endpoint, which is rare. In addition, the state of the field can become incorrect in many other cases. We simply rely on determining the correctness on FindPaths, which is still very fast.

Now in the end Initializewe can call ToggleDestinationwith the central tile as an argument, instead of explicitly calling FindPaths. This is the only time we start with an invalid field state, but we are guaranteed to end with the correct state.

	public void Initialize (
		Vector2Int size, GameTileContentFactory contentFactory
	) {
		…
		//FindPaths();
		ToggleDestination(tiles[tiles.Length / 2]);
	}

Finally, we force to Gamecall ToggleDestinationinstead of setting the contents of the tile itself.

	void HandleTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			//tile.Content =
				//tileContentFactory.Get(GameTileContentType.Destination);
			board.ToggleDestination(tile);
		}
	}


Multiple endpoints with correct paths.

Shouldn't we ban Game from setting the contents of the tile directly?
Ideally, yes. We can make tiles hidden for the field. But for now, we will not bother with this, because Gameeither another code in the future may need access to the tiles for other purposes. When this becomes clear, we can come to a better solution.

Walls


The goal of tower defense is to prevent enemies from reaching the final point. This goal is achieved in two ways. First, we kill them, and secondly, we slow them down so that there is more time to kill them. On the tile field, time can be stretched, increasing the distance that enemies need to go. This can be achieved by placing obstacles on the field. Usually these are towers that also kill enemies, but in this tutorial we will limit ourselves to walls only.

Content


Walls are another type of content, so let's add GameTileContentTypean element to them.

public enum GameTileContentType {
	Empty, Destination, Wall
}

Then create the wall prefab. This time we will create a game object of the contents of the tile and add a child cube to it, which will be on top of the field and fill the entire tile. Make it half a unit high and save the collider, because the walls can visually overlap part of the tiles behind it. Therefore, when a player touches a wall, he will influence the corresponding tile.

root

cube

prefab

Prefab Wall.

Add the wall prefab to the factory, both in the code and in the inspector.

	[SerializeField]
	GameTileContent wallPrefab = default;
	…
	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			case GameTileContentType.Destination: return Get(destinationPrefab);
			case GameTileContentType.Empty: return Get(emptyPrefab);
			case GameTileContentType.Wall: return Get(wallPrefab);
		}
		Debug.Assert(false, "Unsupported type: " + type);
		return null;
	}


Factory with prefab wall.

Turn walls on and off


Add to GameBoardthe on / off method of the walls, as we did for the end point. Initially, we will not check the incorrect state of the field.

	public void ToggleWall (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else {
			tile.Content = contentFactory.Get(GameTileContentType.Wall);
			FindPaths();
		}
	}

We will provide support for switching only between empty tiles and wall tiles, not allowing walls to directly replace endpoints. Therefore, we will only create a wall when the tile is empty. In addition, the walls should block the search for the path. But each tile must have a path to the end point, otherwise the enemies get stuck. To do this, we again need to use validation FindPaths, and discard the changes if they created an incorrect field state.

		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Wall);
			if (!FindPaths()) {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
		}

Turning walls on and off will be used much more often than turning endpoints on and off, so we will make switching walls in the Gamemain touch. The endpoints can be switched by an additional touch (usually the right mouse button), which can be recognized by passing to a Input.GetMouseButtonDownvalue of 1.

	void Update () {
		if (Input.GetMouseButtonDown(0)) {
			HandleTouch();
		}
		else if (Input.GetMouseButtonDown(1)) {
			HandleAlternativeTouch();
		}
	}
	void HandleAlternativeTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			board.ToggleDestination(tile);
		}
	}
	void HandleTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			board.ToggleWall(tile);
		}
	}


Now we have the walls.

Why do I get large gaps between the shadows of diagonally adjacent walls?
This happens because the wall cubes barely touch each other on the diagonals, and the shadows are slightly offset to avoid artifacts. the gaps can be turned off by reducing the lighting offset, reducing the far clipping plane of the camera and increasing the resolution of the shadow map. For example, I reduced the far plane to 20 and set the zero normal deviation of the lighting. In addition, in conjunction with the standard directional shadows, the MSAA creates artifacts, so I turned it off.

Let's also make sure that endpoints cannot directly replace walls.

	public void ToggleDestination (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Destination) {
			…
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Destination);
			FindPaths();
		}
	}

Path Search Lock


In order for the walls to block the search for the path, it’s enough for us not to add tiles with walls to the search border. This can be done by forcing GameTile.GrowPathTonot to return tiles with walls. But the path should still grow in the direction of the wall, so that all the tiles on the field have a path. This is necessary because it is possible that a tile with enemies will suddenly turn into a wall.

	GameTile GrowPathTo (GameTile neighbor) {
		if (!HasPath || neighbor == null || neighbor.HasPath) {
			return null;
		}
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
		return
			neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
	}

To ensure that all tiles have a path, they GameBoard.FindPathsmust check this after the search is complete. If this is not the case, then the state of the field is invalid and needs to be returned false. It is not necessary to update the path visualization for invalid states, because the field will return to the previous state.

	bool FindPaths () {
		…
		foreach (GameTile tile in tiles) {
			if (!tile.HasPath) {
				return false;
			}
		}
		foreach (GameTile tile in tiles) {
			tile.ShowPath();
		}
		return true;
	}


Walls affect the way.

To make sure that the walls actually have the right paths, you need to make the cubes translucent.


Transparent walls.

Note that the requirement of correctness of all paths does not allow walls to enclose a part of the field in which there is no end point. We can split the map, but only if there is at least one endpoint in each part. In addition, each wall must be adjacent to an empty tile or end point, otherwise it will not be able to have a path. For example, it is impossible to make a solid block of 3 × 3 walls.

Hide the way


Visualization of the paths allows us to see how the path search works and make sure that it is indeed correct. But it does not need to be shown to the player, or at least not necessarily. Therefore, let's provide the ability to turn off the arrows. This can be done by adding to the GameTilegeneral method HidePath, which simply disables its arrow.

	public void HidePath () {
		arrow.gameObject.SetActive(false);
	}

The path mapping state is part of the field state. Add a GameBoardboolean field to the default equal falseto track its state, as well as a common property as a getter and setter. The setter must show or hide paths on all tiles.

	bool showPaths;
	public bool ShowPaths {
		get => showPaths;
		set {
			showPaths = value;
			if (showPaths) {
				foreach (GameTile tile in tiles) {
					tile.ShowPath();
				}
			}
			else {
				foreach (GameTile tile in tiles) {
					tile.HidePath();
				}
			}
		}
	}

Now the method FindPathsshould show updated paths only if rendering is enabled.

	bool FindPaths () {
		…
		if (showPaths) {
			foreach (GameTile tile in tiles) {
				tile.ShowPath();
			}
		}
		return true;
	}

By default, path visualization is disabled. Turn off the arrow in the tile prefab.


The prefab arrow is inactive by default.

We make it so that it Gameswitches the visualization state when a key is pressed. It would be logical to use the P key, but it is also a hotkey to enable / disable game mode in the Unity editor. As a result, the visualization will switch when the hotkey to exit the game mode is used, which does not look very nice. So let's use the V key (short for visualization).


No arrows.

Grid display


When the arrows are hidden, it becomes difficult to discern the location of each tile. Let's add the grid lines. Download a square border mesh texture from here that can be used as a separate tile outline.


Mesh texture.

We will not add this texture individually to each tile, but apply it to the ground. But we will make this grid optional, as well as the visualization of paths. Therefore, we will add to GameBoardthe configuration field Texture2Dand select a mesh texture for it.

	[SerializeField]
	Texture2D gridTexture = default;


Field with mesh texture.

Add another Boolean field and a property to control the state of the grid visualization. In this case, the setter must change the material of the earth, which can be implemented by calling the earth and gaining access to the property of the result. If the grid needs to be displayed, then we will assign the grid texture to the material property . Otherwise, assign it to him . Note that when you change the texture of the material, duplicates of the material instance will be created, so it becomes independent of the material asset.GetComponentmaterialmainTexturenull

	bool showGrid, showPaths;
	public bool ShowGrid {
		get => showGrid;
		set {
			showGrid = value;
			Material m = ground.GetComponent().material;
			if (showGrid) {
				m.mainTexture = gridTexture;
			}
			else {
				m.mainTexture = null;
			}
		}
	}

Let us Gameswitch the visualization of the grid with the G key.

	void Update () {
		…
		if (Input.GetKeyDown(KeyCode.G)) {
			board.ShowGrid = !board.ShowGrid;
		}
	}

Also, add the default mesh visualization to Awake.

	void Awake () {
		board.Initialize(boardSize, tileContentFactory);
		board.ShowGrid = true;
	}


Unscaled grid.

So far we've got a border around the entire field. It matches the texture, but that’s not what we need. We need to scale the main texture of the material so that it matches the size of the grid. You can do this by calling the SetTextureScalematerial method with the name of the texture property ( _MainTex ) and two-dimensional size. We can use directly the size of the field, which is indirectly converted to a value Vector2.

			if (showGrid) {
				m.mainTexture = gridTexture;
				m.SetTextureScale("_MainTex", size);
			}

without

with

Scaled grid with path visualization turned off and on.

So, at this stage, we got a functioning field for a tile game of tower defense genre. In the next tutorial we will add enemies. PDF

Repository


Also popular now: