[Перевод] Создание игры Tower Defense в Unity, часть 1


  • Создание тайлового поля.
  • Поиск путей с помощью поиска в ширину.
  • Реализация поддержки пустых и конечных тайлов, а также тайлов стен.
  • Редактирование контента в режиме игры.
  • Опциональное отображение сетки поля и путей.


Это первая часть серии туториалов, посвящённых созданию простой игры в жанре tower defense. В этой части мы рассмотрим создание игрового поля, поиск пути и размещение конечных тайлов и стен.

Туториал создавался в Unity 2018.3.0f2.

c3dae459169a0a7f0ce240763b91d55c.jpg


Поле, готовое к использованию в тайловой игре жанра tower defense.

Игра жанра Tower Defense


Tower defense — это жанр, в которой целью игрока является уничтожение толп врагов, пока они не добрались до своей конечной точки. Игрок выполняет свою цель, строя башни, которые атакуют врагов. У этого жанра очень много вариаций. Мы будем создавать игру с тайловым полем. Враги будут двигаться по полю в сторону своей конечной точки, а игрок будет создавать им препятствия.
Я буду считать, что вы уже изучили серию туториалов по управлению объектами.

Поле


Игровое поле — самая важная часть игры, поэтому его мы создадим первым. Это будет игровой объект (game object) с собственным компонентом GameBoard, который можно инициализировать заданием размера в двух измерениях, для чего мы можем воспользоваться значением Vector2Int. Поле должно работать с любым размером, но выбирать размер мы будем где-нибудь в другом месте, поэтому создадим для этого общий метод Initialize.

Кроме того, мы визуализируем поле одним четырёхугольником (quad), который будет обозначать землю. Мы не будем делать четырёхугольником сам объект поля, а добавим ему дочерний объект quad. При инициализации мы сделаем масштаб XY земли равным размеру поля. То есть каждый тайл будет иметь размер в одну квадратную единицу измерения движка.

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);
	}
	}


Зачем явным образом задавать ground значение по умолчанию?

Идея заключается в том, что всё настраиваемое через редактор Unity, доступно через сериализированные скрытые поля. Нужно, чтобы эти поля можно было менять только в инспекторе. К сожалению, редактор Unity постоянно будет показывать предупреждение компилятора о том, что значение никогда не присваивается. Мы можем подавить это предупреждение, явно задав полю значение по умолчанию. Можно присвоить и null, но я сделал так, чтобы явно показать, что мы просто используем значение по умолчанию, которое не представляет собой истинную ссылку на ground, поэтому применяем default.


Создадим объект поля в новой сцене и добавим ему дочерний quad с материалом, который выглядит как земля. Так как мы создаём простую игру-прототип, вполне достаточно будет однородного зелёного материала. Повернём quad на 90° по оси X, чтобы он лежал на плоскости XZ.

db99b89afcc551541f1f6adbe4816e54.png


fa8f7c77d6697bca5e3c8823f5adcd86.png


f2a467015b8eefc8ca956eaec61d50dc.png


Игровое поле.

Почему бы не расположить игру на плоскости XY?

Хоть игра будет проходить в 2D-пространстве, рендерить мы её будем в 3D, с 3D-врагами и камерой, которую можно двигать относительно определённой точки. Плоскость XZ более удобна для этого и соответствует стандартной ориентации скайбокса, используемой для окружающего освещения.


Игра


Далее создадим компонент Game, который будет отвечать за всю игру. На данном этапе это будет означать, что он инициализирует поле. Мы просто сделаем размер настраиваемым через инспектор и заставим компонент инициализировать поле при его пробуждении. Давайте используем по умолчанию размер 11×11.

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


Размеры поля могут быть только положительными и не имеет особого смысла создавать поле с единственным тайлом. Поэтому давайте ограничим минимум размером 2×2. Это можно сделать, добавив метод OnValidate, принудительно ограничивающий минимальные значения.

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


Когда вызывается Onvalidate?
Если он существует, то редактор Unity вызывает его для компонентов после их изменения. В том числе при добавлении их к game object, после загрузки сцены, после рекомпиляции, после изменения в редакторе, после отмены/повтора и после сброса компонента.

OnValidate — это единственное место в коде, где допускается присвоение значений полям конфигурации компонентов.


7ae15defd1e838186f922fc30d346b17.png


Game object.

Теперь при запуске режима игры мы будем получать поле с верным размером. Во время игры расположите камеру так, чтобы была видна вся доска, скопируйте её компонент transformation, выйдите из режима игры (play mode) и вставьте значения компонента. В случае поля размером 11×11, находящегося в начале координат, для получения удобного вида сверху можно расположить камеру в позиции (0,10,0) и повернув её на 90° по оси X. Мы оставим камеру в этом фиксированном положении, но возможно изменим его в будущем.

eb545c421fdca09acf1258a2fac9e183.png


Камера над полем.

Как копировать и вставлять значения компонентов?

Через раскрывающееся меню, которое появляется при нажатии на кнопку с шестерёнкой в правом верхнем углу компонента.


Префаб тайла


Поле состоит из квадратных тайлов. Враги смогут передвигаться с тайла на тайл, пересекая края, но не по диагонали. Движение всегда будет происходит по направлению к ближайшей конечной точке. Давайте графически обозначим направление движения по тайлу стрелкой. Скачать текстуру стрелки можно здесь.

tfl6oszqhi93v6aqlllrp1yuhli.png


Стрелка на чёрном фоне.

Поместите текстуру стрелки в свой проект и включите опцию Alpha As Transparency. Затем создайте для стрелки материал, который может быть стандартным материалом (default material), для которого выбран режим cutout, а в качестве основной текстуры выберите стрелку.

bfb2d656c07931b8f59ef11715eee93b.png


Материал стрелки.

Зачем использовать режим рендеринга cutout?

Он позволяет затенять стрелку при использовании стандартного конвейера рендеринга Unity.


Для обозначения каждого тайла в игре мы будем использовать game object. Каждый из них будет иметь свой quad с материалом стрелки, так же, как у поля есть quad земли. Также мы добавим тайлам компонент GameTile со ссылкой на их стрелку.

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


Создайте объект тайла и превратите его в префаб. Тайлы будут находиться вровень с землёй, поэтому приподнимите стрелку немного вверх, чтобы избежать проблем с глубиной при рендеринге. Также немного уменьшите масштаб стрелки, чтобы между соседними стрелками было немного пространства. Подойдёт смещение по Y 0.001 и одинаковый для всех осей масштаб 0.8.

f1202b6d8f8f33593bf56ef0d8665537.png


9641aca4b874f48c3765af10e2c83157.png


df77a3c9f539083eab845be6b9c62919.png


Префаб тайла.

Где находится иерархия префаба тайла?

Режим редактирования префаба можно открыть, дважды щёлкнув на ассет префаба, или выбрав префаб и нажав на кнопку Open Prefab в инспекторе. Выйти из режима редактирования префаба можно, нажав на кнопку со стрелкой в левом верхнем углу его заголовка иерархии.


Учтите, что сами тайлы необязательно должны быть game objects. Они нужны только для того, чтобы отслеживать состояние поля. Мы могли бы использовать тот же подход, что и для поведения в серии туториалов Object Management. Но на ранних этапах простых игр или прототипов game objects вполне нас устраивают. В будущем это можно будет изменить.

Располагаем тайлы


Для создания тайлов GameBoard должен иметь ссылку на префаб тайла.

 [SerializeField]
	GameTile tilePrefab = default;


3636c204442368c58858152a7c3efee2.png


Ссылка на префаб тайла.

Затем он может создать его экземляры с помощью двойного цикла по двум измерениям сетки. Хоть размер и выражен как X и Y, мы будем располагать тайлы на плоскости XZ, как и само поле. Так как поле центрировано относительно точки начала координат, нам нужно вычесть из компонентов позиции тайла соответствующий размер минус один, разделённый на два. Учтите, что это должно быть деление с плавающей запятой, в противном случае для чётных размеров оно не сработает.

	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
				);
			}
		}
	}


391265640fe99dd10be005de76ce93f3.png


Созданные экземпляры тайлов.

Позже нам понадобится доступ к этим тайлам, поэтому будем отслеживать их в массиве. Нам не нужен список, потому что после инициализации размер поля меняться не будет.

	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);
				…
			}
		}
	}


Как работает это присвоение?
Это сцеплённое присвоение. В данном случае это означает, что мы присваиваем ссылку на экземпляр тайла и элементу массива, и локальной переменной. Эти операции выполняют то же, что и показанный ниже код.
GameTile t = Instantiate(tilePrefab);
tiles[i] = t;
GameTile tile = t;


Поиск пути


На этом этапе у каждого тайла есть стрелка, но все они указывают в положительном направлении оси Z, которое мы будем интерпретировать как север. Следующим этапом будет определение правильного направления для тайла. Мы реализуем это нахождением пути, по которому враги должны следовать к конечной точке.

Соседи тайлов


Пути идут от тайла к тайлу, в северном, восточном, южном или западном направлении. Чтобы упростить поиск, заставим GameTile отслеживать ссылки на четырёх его соседей.

	GameTile north, east, south, west;


Отношения между соседями симметричны. Если тайл является восточным соседом второго тайла, то второй является западным соседом первого. Добавим к GameTile общий статический метод для определения этих отношений между двумя тайлами.

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


Зачем использовать статический метод?

Мы можем сделать его и методом экземпляра с единственным параметром, и в таком случае будем вызывать его как eastTile.MakeEastWestNeighbors(westTile) или что-то подобное. Но в случаях, когда непонятно, каким из тайлов должен быть вызван метод, лучше использовать статические методы. Примерами являются методы Distance и Dot класса Vector3.


После установления связи она никогда не должна меняться. Если это случится, то мы совершили ошибку в коде. Можно проверять это, сравнивая обе ссылки перед присваиванием значений с null, и выводя в консоль ошибку, если это неверно. Для этого можно использовать метод 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;
	}


Что делает Debug.Assert?

Если первый аргумент равен false, то он выводит ошибку условия, используя для этого второй аргумент, если он указан. Такой вызов включается только в тестовые сборки, но не в релизные. Поэтому это хороший способ добавления проверок в процессе разработки, которые не повлияют на финальный релиз.


Добавим аналогичный метод для создания отношений между северными и южными соседями.

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


Мы можем установить эти отношения при создании тайлов в GameBoard.Initialize. Если координата X больше нуля, то мы можем создать отношение восток-запад между текущим и предыдущим тайлом. Если координата Y больше нуля, то мы можем создать отношение север-юг между текущим тайлом и тайлом из предыдущей строки.

		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]);
				}
			}
		}


Учтите, что тайлы на краях поля имеют не четырёх соседей. Одна или две ссылки на соседей будут оставаться равными null.

Расстояние и направление


Мы не будем заставлять всех врагов постоянно искать путь. Это необходимо делать только один раз за тайл. Тогда враги смогут запрашивать у тайла, в котором находятся, куда двигаться дальше. Мы будем хранить эту информацию в GameTile, добавив ссылку на следующий тайл пути. Кроме того, мы также сохраним расстояние до конечной точки, выраженную в виде количества тайлов, которые нужно посетить, прежде чем враг достигнет конечной точки. Для врагов эта информация бесполезна, но мы будем применять её для нахождения кратчайших путей.

	GameTile north, east, south, west, nextOnPath;

	int distance;


Каждый раз, когда мы решим, что нужно искать пути, нам нужно будет инициализировать данные пути. Пока путь не найден, следующего тайла нет и расстояние можно считать бесконечным. Мы можем представить это максимальным возможным целочисленным значением int.MaxValue. Добавим общий метод ClearPath, чтобы выполнить сброс GameTile к этому состоянию.

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


Пути можно искать, только если у нас есть конечная точка. Это значит, что тайл должен стать конечной точкой. Такой тайл имеет расстояние, равное нулю, и у него нет последнего тайла, потому что путь завершается на нём. Добавим общий метод, превращающий тайл в конечную точку.

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


В конечном итоге все тайлы должны превратиться в путь, поэтому их расстояние больше не будет равно int.MaxValue. Добавим удобное свойство-геттер, чтобы проверять, есть ли в данный момент у тайла путь.

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


Как работает это свойство?
Это укороченная запись задания свойства-геттера, содержащего только одно выражение. Она делает то же самое, что и показанный ниже код.
	public bool HasPath {
		get {
			return distance != int.MaxValue;
		}
	}

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


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


Если у нас есть тайл с путём, то мы можем позволить ему вырастить путь по направлению к одному из соседей. Изначально единственным тайлом с путём является конечная точка, поэтому мы начинаем с нулевого расстояния и увеличиваем его отсюда, перемещаясь в противоположном движению врагов направлении. То есть все непосредственные соседи конечной точки будут иметь расстояние 1, а все соседи этих тайлов — расстояние 2, и так далее.

Добавим GameTile скрытый метод для выращивания пути к одному из его соседей, задаваемому через параметр. Расстояние до соседа становится на единицу больше, чем у текущего тайла, а путь соседа указывает на текущий тайл. Этот метод должен вызываться только для тех тайлов, у которых уже есть путь, так что давайте проверять это с помощью assert.

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


Идея заключается в том, что мы вызываем этот метод один раз для каждого из четырёх соседей тайла. Так как некоторые из этих ссылок будут равны null, то мы будем проверять это и прекращать выполнение, если это так. Кроме того, если у соседа уже есть путь, то мы ничего не должны делать и тоже прекращаем выполнение.

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


То, как GameTile отслеживает своих соседей, неизвестно остальному коду. Поэтому GrowPathTo является скрытым. Мы добавим общие методы, приказывающие тайлу вырастить его путь в определённом направлении, косвенно вызывая GrowPathTo. Но код, который занимается поиском по всему полю, должен отслеживать, какие тайлы были посещены. Поэтому сделаем так, чтобы он возвращал соседа или null, если выполнение прекращено.

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


Теперь добавим методы для выращивания пути в конкретных направлениях.

	public GameTile GrowPathNorth () => GrowPathTo(north);

	public GameTile GrowPathEast () => GrowPathTo(east);

	public GameTile GrowPathSouth () => GrowPathTo(south);

	public GameTile GrowPathWest () => GrowPathTo(west);


Поиск в ширину


Гарантировать, что все тайлы содержат верные данные пути, должен GameBoard. Мы реализуем это выполнением поиска в ширину (breadth-first search). Начнём с тайла конечной точки, а затем вырастим путь до его соседей, потом до соседей этих тайлов, и так далее. С каждым шагом расстояние увеличивается на единицу, а пути никогда не растут в сторону тайлов, у которых уже есть пути. Это гарантирует, что все тайлы в результате будут указывать вдоль кратчайшего пути к конечной точке.

А как насчёт поиска пути с помощью A*?
Алгоритм A* — это эволюционное развитие поиска в ширину. Он полезен, когда мы ищем единственный кратчайший путь. Но нам нужны все кратчайшие пути, поэтому A* не даёт никаких преимуществ. Примеры поиска в ширину и A* на сетке из шестиугольников с анимацией см. в серии туториалов про карты из шестиугольников.


Для выполнения поиска нам нужно отслеживать тайлы, которые мы добавили к пути, но из которых пока не вырастили путь. Эту коллекцию тайлов часто называют границей поиска (search frontier). Важно, чтобы тайлы обрабатывались в том же порядке, в котором они добавляются к границе, поэтому давайте используем очередь Queue. Позже нам придётся выполнять поиск несколько раз, поэтому зададим её как поле (field) GameBoard.

using UnityEngine;
using System.Collections.Generic;

public class GameBoard : MonoBehaviour {

	…

	Queue searchFrontier = new Queue();

	…
}


Чтобы состояние игрового поля всегда было верным, мы должны находить пути в конце Initialize, но поместить код в отдельный метод FindPaths. Первым делом нужно очистить путь у всех тайлов, затем сделать один тайл конечной точкой и добавить его к границе. Давайте сначала выберем первый тайл. Так как tiles является массивом, мы можем использовать цикл foreach, не боясь загрязнения памяти. Если позже мы перейдём от массива к списку, то нужно будет также заменить циклы foreach циклами for.

	public void Initialize (Vector2Int size) {
		…

		FindPaths();
	}

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


Далее нам нужно взять один тайл из границы и вырастить путь ко всем его соседям, добавив их все в границу. Сначала двинемся на север, потом на восток, юг и наконец запад.

	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());
	}


Повторяем этот этап, пока в границе есть тайлы.

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


Выращивание пути не всегда приводит нас к новому тайлу. Перед добавлением в очередь нам нужно проверять значение на null, но можно и отложить проверку на null до момента после вывода из очереди.

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


Отображаем пути


Теперь у нас есть поле, содержащее верные пути, но пока мы этого не видим. Надо настроить стрелки так, чтобы они указывали вдоль пути через их тайлы. Это можно сделать, повернув их. Так как эти повороты всегда одинаковы, добавим в GameTile по одному статическому полю Quaternion для каждого из направлений.

	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);


Также добавим общий метод ShowPath. Если расстояние равно нулю, то тайл является конечной точкой и ему не на что указывать, поэтому деактивируем его стрелку. В противном случае активирум стрелку и задаём её поворот. Нужное направление можно определить, сравнив nextOnPath с его соседями.

	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;
	}


Вызовем этот метод для всех тайлов в конце GameBoard.FindPaths.

	public void FindPaths () {
		…

		foreach (GameTile tile in tiles) {
			tile.ShowPath();
		}
	}


b2d4fe4d511b4e729910ea78b267f268.png


Найденные пути.

Почему мы не поворачиваем стрелку непосредственно в GrowPathTo?

Чтобы разделить логику и визуализацию поиска. Позже мы сделаем визуализацию отключаемой. Если стрелки не отображаются, нам не нужно поворачивать их каждый раз при вызове FindPaths.


Изменяем приоритет поиска


Оказывается, что когда конечной точкой является юго-западный угол, все пути идут ровно на запад, пока не достигнут края поля, после чего поворачивают на юг. Здесь всё верно, потому что более кратких путей к конечной точке и в самом деле нет, ведь диагональные перемещения невозможны. Однако существует множество других кратчайших путей, которые могут выглядеть красивее.

Чтобы лучше понимать, почему находятся такие пути, переместим конечную точку в центр карты. При нечётном размере поля это просто тайл в середине массива.

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


6d3d09d7844fbe6a4aaf4618449598de.png


Конечная точка в центре.

Результат кажется логичным, если вспомнить, как работает поиск. Так как мы добавляем соседей в порядке «север-восток-юг-запад», наивысший приоритет имеет север. Так как мы выполняем поиск в обратном порядке, это значит, что последним пройденным направлением оказывается юг. Именно поэтому всего несколько стрелок указывает на юг и многие указывают на восток.

Изменить результат можно, настроив приоритеты направлений. Давайте поменяем местами восток и юг. Так мы должны получить симметрию «север-юг» и «восток-запад».

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


a9a2c70b7d7d178b041f30c11d256ad3.png


Порядок поиска «север-юг-восток-запад».

Это выглядит красивее, но лучше, чтобы пути меняли направление, приближаясь к движению по диагонали там, где это будет выглядеть естественно. Мы можем сделать это, перевернув приоритеты поиска соседних тайлов в шахматном порядке.

Вместо того, чтобы выяснять, какой тип тайла мы обрабатываем во время поиска, добавим в GameTile общее свойство, указывающее, является ли текущий тайл альтернативным.

	public bool IsAlternative { get; set; }


Это свойство мы будем задавать в GameBoard.Initialize. Сначала пометим тайлы как альтернативные, если их координата X чётная.

		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…

				tile.IsAlternative = (x & 1) == 0;
			}
		}


Что делает операция (x & 1) == 0?
Одиночный амперсанд — это двоичный оператор И (AND). Он выполняет логическую операцию И для каждой отдельной пары битов его операндов. Поэтому чтобы конечный бит был равен 1, оба бита пары должны быть равны 1. Например 10101010 и 00001111 дают нам 00001010.

В компьютерах числа хранятся в двоичном виде. В них могут использоваться только 0 и 1. В двоичном виде последовательность 1, 2, 3, 4 записывается как 1, 10, 11, 100. Как видите, самый младший разряд чётных чисел равен нулю.

Мы используем двоичное AND как маску, игнорируя всё, кроме самого младшего разряда. Если результат равен нулю, то мы имеем дело с чётным числом.


Во-вторых, изменим знак результата, если их координата Y чётная. Так мы создадим шахматный узор.

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


В FindPaths мы сохраним тот же порядок поиска для альтернативных тайлов, но сделаем его обратным для всех прочих тайлов. Это заставит пути стремиться к диагональному движению и создавать зигзаги.

			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());
				}
			}


b43e3ad6370a6b552073b4f0b4cf0e8b.png


Переменный порядок поиска.

Изменяем тайлы


На этом этапе все тайлы пусты. Один тайл используется как конечная точка, но кроме отсутствия видимой стрелки он выглядит так же, как все остальные. Мы добавим возможность изменения тайлов размещением на них объектов.

Содержимое тайла


Сами по себе объекты тайлов — это просто способ отслеживания информации о тайле. Мы не изменяем эти объекты напрямую. Вместо этого добавим отдельное содержимое и разместим его на поле. Пока мы можем различать пустые тайлы и тайл конечной точки. Для обозначения этих случаев создадим перечисление GameTileContentType.

public enum GameTileContentType {
	Empty, Destination
}


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

using UnityEngine;

public class GameTileContent : MonoBehaviour {

	[SerializeField]
	GameTileContentType type = default;

	public GameTileContentType Type => type;
}


Затем создадим префабы для двух типов контента, у каждого из которых компонент GameTileContent с соответствующим заданным типом. Давайте для обозначения тайлов конечных точек воспользуемся голубым сплющенным кубом. Так как он почти плоский, коллайдер ему не нужен. Для префаба пустого содержимого используем пустой game object.

destination


empty


Префабы конечной точки и пустого содержимого.

Мы дадим пустым тайлам объект содержимого, потому что тогда у всех тайлов всегда будет содержимое, а значит, нам не нужно будет проверять ссылки на содержимое на равенство null.

Фабрика содержимого


Чтобы сделать содержимое редактируемым, мы также создадим для этого фабрику, воспользовавшись тем же подходом, что и в туториале Object Management. Это значит, что GameTileContent должен отслеживать свою исходную фабрику, которая должна задаваться только один раз, и отправлять себя обратно на фабрику в методе Recycle.

	GameTileContentFactory originFactory;

	…

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

	public void Recycle () {
		originFactory.Reclaim(this);
	}


Это предполагает существование GameTileContentFactory, поэтому создадим для этого тип scriptable object с обязательным методом Recycle. На данном этапе мы пока не будем заморачиваться созданием полнофункциональной фабрики, утилизирующей содержимое, поэтому заставим её просто уничтожать содержимое. Позже можно будет добавить к фабрике многократное использование объектов без изменения всего остального кода.

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);
	}
}


Добавим фабрике скрытый метод Get с префабом в качестве параметра. Здесь мы снова пропустим многократное применение объектов. Он создаёт экземпляр объекта, задаёт его исходную фабрику, перемещает его на сцену фабрики и возвращает его.

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


Экземпляр перемещён на сцену содержимого фабрики, которую можно создавать по необходимости. Если мы находимся в редакторе, то прежде чем создавать сцену, нужно проверить, существует ли она, на случай, если мы потеряем её из виду при горячем перезапуске.

	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);
	}


У нас есть только два типа содержимого, поэтому просто добавим для них два поля конфигурации префабов.

	[SerializeField]
	GameTileContent destinationPrefab = default;

	[SerializeField]
	GameTileContent emptyPrefab = default;


Последнее, что нужно сделать для работы фабрики — создать общий метод Get с параметром GameTileContentType, получающий экземпляр соответствующего префаба.

	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;
	}


Обязательно ли добавлять каждому тайлу собственный экземпляр пустого содержимого?

Поскольку пустой контент никак не отображается графически, мы можем обойтись созданием экземпляра объекта пустого содержимого и многократным его использованием для всех тайлов. Но на данном этапе нам необязательно заниматься такими оптимизациями. Кроме того, к пустым тайлам всё равно можно добавить какую-нибудь визуализацию, например, камни, траву, цветы и так далее. Можно даже смешивать разные визуализации, заставляя фабрику каждый раз возвращать случайный элемент. Пока нам это не нужно, но в дальнейшем мы можем внести это изменение, просто изменив фабрику.


Создадим ассет фабрики и настроим её ссылки на префабы.

7eb75f1cd4bed3c72ffd7f0c42b69cbe.png


Фабрика содержимого.

А затем передадим Game ссылку на фабрику.

	[SerializeField]
	GameTileContentFactory tileContentFactory = default;


b12d6a5067fa91177dbed14a876f8b3d.png


Game с фабрикой.

Касание тайла


Чтобы изменять поле, нам нужно иметь возможность выбора тайла. Мы сделаем так, чтобы это было возможно в режиме игры. Будем испускать луч в сцену в месте, где игрок нажал на окно игры. Если луч пересекается с тайлом, то его коснулся игрок, то есть его необходимо изменить. Game будет обрабатывать ввод игрока, но за определение того, какого тайла коснулся игрок, будет отвечать GameBoard.

Не все лучи пересекутся с тайлом, поэтому иногда мы не будем получать ничего. Поэтому добавим в GameBoard метод GetTile, который изначально всегда возвращает null (это означает, что тайл не был найден).

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


Чтобы определить, пересёк ли луч тайл, нам нужно вызвать Physics.Raycast, указав в качестве аргумента луч. Он возвращает информацию о том, было ли пересечение. Если да, то мы сможем вернуть тайл, хоть пока и не знаем какой, поэтому пока возвращаем null.

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


Чтобы узнать, было ли пересечение с тайлом, нам нужно больше информации о пересечении. Physics.Raycast может предоставить эту информацию с помощью второго параметра RaycastHit. Это выходной параметр, что обозначается словом out перед ним. Это означает, что вызов метода может присвоить значение переменной, которую мы ему передаём.

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


Мы можем встроить объявление переменных, используемых для выходных параметров, поэтому давайте так и сделаем.

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


Нас не волнует, с каким именно коллайдером произошло пересечение, мы просто используем позицию XZ пересечения, чтобы определить тайл. Координаты тайла мы получаем, прибавив к координатам точки пересечения половину размера поля, а затем преобразовав результаты в целые значения. Окончательный индекс тайла в результате будет его координатой X плюс координатой Y, умноженной на ширину поля.

		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];
		}


Но это возможно только когда координаты тайла находятся в пределах поля, поэтому будем проверять это. Если это не так, то тайл не возвращаем.

			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];
			}


Изменение содержимого


Чтобы можно было изменять содержимое тайла, добавим к GameTile общее свойство Content. Его геттер просто возвращает содержимое, а сеттер утилизирует предыдущее содержимое, если оно было, и размещает новое содержимое.

	GameTileContent content;

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


Это единственное место, где нужно проверять содержимое на null, потому что изначально у нас нет содержимого. Для гарантии выполним assert, чтобы сеттер не вызывался с null.

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


И, наконец, нам нужен ввод игрока. Преобразование щелчка мыши в луч можно выполнить вызовом ScreenPointToRay с Input.mousePosition в качестве ар

© Habrahabr.ru