11/06/2019

Создание игры "Tower Defense" Jasper Flick. Часть 1

Это первая часть серии туториалов, посвящённых созданию простой игры в жанре tower defense. В этой части мы рассмотрим создание игрового поля, поиск пути и размещение конечных тайлов и стен.
Оригинальная ссылка https://catlikecoding.com/unity/tutorials/tower-defense/
Ссылка на перевод https://habr.com/ru/post/449798/
Автор оригинала: Jasper Flick
Поле
  • Создание тайлового поля.
  • Поиск путей с помощью поиска в ширину.
  • Реализация поддержки пустых и конечных тайлов, а также тайлов стен.
  • Редактирование контента в режиме игры.
  • Опциональное отображение сетки поля и путей.

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

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

Поле, готовое к использованию в тайловой игре жанра 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 значение по умолчанию?

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



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

Игра


Далее создадим компонент 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?


Game object.

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

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

Префаб тайла


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

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

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

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

Для обозначения каждого тайла в игре мы будем использовать game object. Каждый из них будет иметь свой quad с материалом стрелки, так же, как у поля есть quad земли. Также мы добавим тайлам компонент GameTile со ссылкой на их стрелку.
using UnityEngine;
 
 public class GameTile : MonoBehaviour {
 
 [SerializeField]
 Transform arrow = default;
 }

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



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

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

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


Для создания тайлов GameBoard должен иметь ссылку на префаб тайла.
 [SerializeField]
 GameTile tilePrefab = default;


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

Затем он может создать его экземляры с помощью двойного цикла по двум измерениям сетки. Хоть размер и выражен как 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
    );
   }
  }
 }


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

Позже нам понадобится доступ к этим тайлам, поэтому будем отслеживать их в массиве. Нам не нужен список, потому что после инициализации размер поля меняться не будет.
 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);
    …
   }
  }
 }

Как работает это присвоение?

Поиск пути


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

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


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

Отношения между соседями симметричны. Если тайл является восточным соседом второго тайла, то второй является западным соседом первого. Добавим к GameTile общий статический метод для определения этих отношений между двумя тайлами.
 public static void MakeEastWestNeighbors (GameTile east, GameTile west) {
  west.east = east;
  east.west = west;
 }

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

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

Добавим аналогичный метод для создания отношений между северными и южными соседями.
 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;

Как работает это свойство?

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


Если у нас есть тайл с путём, то мы можем позволить ему вырастить путь по направлению к одному из соседей. Изначально единственным тайлом с путём является конечная точка, поэтому мы начинаем с нулевого расстояния и увеличиваем его отсюда, перемещаясь в противоположном движению врагов направлении. То есть все непосредственные соседи конечной точки будут иметь расстояние 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*?

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

public class GameBoard : MonoBehaviour {

 …

 Queue<GameTile> searchFrontier = new Queue<GameTile>();

 …
}

Чтобы состояние игрового поля всегда было верным, мы должны находить пути в конце 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();
  }
 }


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

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


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

Чтобы лучше понимать, почему находятся такие пути, переместим конечную точку в центр карты. При нечётном размере поля это просто тайл в середине массива.
  tiles[tiles.Length / 2].BecomeDestination();
  searchFrontier.Enqueue(tiles[tiles.Length / 2]);


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

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

Изменить результат можно, настроив приоритеты направлений. Давайте поменяем местами восток и юг. Так мы должны получить симметрию «север-юг» и «восток-запад».
    searchFrontier.Enqueue(tile.GrowPathNorth());
    searchFrontier.Enqueue(tile.GrowPathSouth());
    searchFrontier.Enqueue(tile.GrowPathEast());
    searchFrontier.Enqueue(tile.GrowPathWest())


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

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

Вместо того, чтобы выяснять, какой тип тайла мы обрабатываем во время поиска, добавим в 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?

Во-вторых, изменим знак результата, если их координата 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());
    }
   }


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

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


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

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


Сами по себе объекты тайлов — это просто способ отслеживания информации о тайле. Мы не изменяем эти объекты напрямую. Вместо этого добавим отдельное содержимое и разместим его на поле. Пока мы можем различать пустые тайлы и тайл конечной точки. Для обозначения этих случаев создадим перечисление 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;
 }

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

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

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

А затем передадим Game ссылку на фабрику.
 [SerializeField]
 GameTileContentFactory tileContentFactory = default;


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 в качестве аргумента. Вызов нужно выполнять для основной камеры, доступ к которой можно получить через Camera.main. Добавим для этого свойство в Game.
  Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition);

Затем добавим метод Update, проверяющий, была ли при обновлении нажата основная клавиша мыши. Для этого нужно вызвать Input.GetMouseButtonDown с нулём в качестве аргумента. Если клавиша была нажата, обрабатываем касание игрока, то есть берём тайл с поля, и задаём в качестве его содержимого конечную точку, взяв её из фабрики.
 void Update () {
  if (Input.GetMouseButtonDown(0)) {
   HandleTouch();
  }
 }

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

Теперь мы можем превратить любой тайл в конечную точку нажатием курсора.

Несколько конечных точек.

Делаем поле правильным


Хоть мы и можем превращать тайлы в конечные точки, это пока не влияет на пути. Кроме того, мы пока не задали пустое содержимое для тайлов. Сохранение правильности и целостности поля является задачей GameBoard, поэтому давайте передадим ему и обязанность задания содержимого тайла. Чтобы реализовать это, передадим ему ссылку на фабрику содержимого через его метод Intialize, и используем её, чтобы дать всем тайлам экземпляр пустого содержимого.
 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();
 }

Теперь Game должен передать свою фабрику полю.
 void Awake () {
  board.Initialize(boardSize, tileContentFactory);
 }

Почему бы не добавить поле конфигурации фабрики в GameBoard?

Так как теперь у нас есть несколько конечных точек, изменим GameBoard.FindPaths так, чтобы он вызывал BecomeDestination для каждой и добавлял их все в границу. И это всё, что нужно для поддержки нескольких конечных точек. Все остальные тайлы как обычно очищаются. Затем мы удаляем жёстко заданную конечную точку в центре.
 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]);

  …
 }

Но если мы можем превращать тайлы в конечные точки, то у нас должна быть возможность выполнять и обратную операцию, превращать конечные точки в пустые тайлы. Но тогда у нас может получиться поле совсем без конечных точек. В таком случае FindPaths не сможет выполнять свою задачу. Это происходит, когда граница пуста после инициализации путей для всех ячеек. Обозначим это как неверное состояние поля, возвращая false и завершая выполнение; в противном случае возвращаем в конце true.
 bool FindPaths () {
  foreach (GameTile tile in tiles) {
   …
  }
  if (searchFrontier.Count == 0) {
   return false;
  }
  
  …
  return true;
 }

Проще всего реализовать поддержку удаления конечных точек, сделав её операцией переключения. Нажав на пустые тайлы, мы будем превращать их в конечные точки, а нажимая на конечные точки, мы будем их удалять. Но теперь изменением содержимого занимается GameBoard, поэтому дадим ему общий метод ToggleDestination, параметром которого является тайл. Если тайл является конечной точкой, то делаем её пустой и вызываем FindPaths. В противном случае делаем его конечной точкой и тоже вызываем 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();
  }
 }

Добавление конечной точки никогда не может создать неверное состояние поля, а удаление конечной точки — может. Поэтому будем проверять, удалось ли успешно выполнить FindPaths после того, как мы сделали тайл пустым. Если нет, то отменяем изменение, снова превратив тайл в конечную точку, и снова вызываем FindPaths, чтобы возвратиться к предыдущему верному состоянию.
  if (tile.Content.Type == GameTileContentType.Destination) {
   tile.Content = contentFactory.Get(GameTileContentType.Empty);
   if (!FindPaths()) {
    tile.Content =
     contentFactory.Get(GameTileContentType.Destination);
    FindPaths();
   }
  }

Можно ли сделать проверку правильности более эффективной?

Теперь в конце Initialize мы можем вызвать ToggleDestination с центральным тайлом в качестве аргумента, вместо того, чтобы явно вызывать FindPaths. Это единственный раз, когда мы начинаем с неверного состояния поля, но закончим мы гарантированно с правильным состоянием.
 public void Initialize (
  Vector2Int size, GameTileContentFactory contentFactory
 ) {
  …

  //FindPaths();
  ToggleDestination(tiles[tiles.Length / 2]);
 }

Наконец, заставим Game вызывать ToggleDestination вместо того, чтобы задавать само содержимое тайла.
 void HandleTouch () {
  GameTile tile = board.GetTile(TouchRay);
  if (tile != null) {
   //tile.Content =
    //tileContentFactory.Get(GameTileContentType.Destination);
   board.ToggleDestination(tile);
  }
 }


Несколько конечных точек с правильными путями.
Разве мы не должны запретить Game задавать содержимое тайла напрямую?

Стены


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

Содержимое


Стены — это ещё один тип содержимого, поэтому добавим в GameTileContentType элемент для них.
public enum GameTileContentType {
 Empty, Destination, Wall
}

Затем создадим префаб стены. На этот раз создадим game object содержимого тайла и добавим ему дочерний куб, который будет находиться поверх поля и заполнять тайл целиком. Сделаем его высотой в половину единицы и сохраним коллайдер, потому что стены могут визуально перекрывать часть тайлов за ним. Поэтому когда игрок касается стены, он будет влиять на соответствующий тайл.
root

cube

prefab

Префаб стены.

Добавим префаб стены в фабрику, и в коде, и в инспекторе.
 [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;
 }


Фабрика с префабом стены.

Включение и отключение стен


Добавим в GameBoard метод включения-отключения стен, как мы сделали это для конечной точки. Изначально проверять неверное состояние поля мы не будем.
 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();
  }
 }

Мы обеспечим поддержку переключения только между пустыми тайлами и тайлами стен, не позволяя стенам напрямую заменять конечные точки. Поэтому создавать стену мы будем только тогда, когда тайл пуст. Кроме того, стены должны блокировать поиск пути. Но каждый тайл должен иметь путь к конечной точке, в противном случае враги застрянут. Для этого нам снова нужно использовать проверку FindPaths, и отменять изменения, если они создали неверное состояние поля.
  else if (tile.Content.Type == GameTileContentType.Empty) {
   tile.Content = contentFactory.Get(GameTileContentType.Wall);
   if (!FindPaths()) {
    tile.Content = contentFactory.Get(GameTileContentType.Empty);
    FindPaths();
   }
  }

Включение-отключение стен будет использоваться гораздо чаще, чем включение-отключение конечных точек, поэтому сделаем так, чтобы переключение стен в Game выполнялось основным касанием. Конечные точки можно переключать дополнительным касанием (обычно это правая клавиша мыши), которое можно распознать, передав в Input.GetMouseButtonDown значение 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);
  }
 }


Теперь у нас есть стены.
Почему у меня получаются большие зазоры между тенями соседних по диагонали стен?

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

Блокировка поиска пути


Чтобы стены блокировали поиск пути, нам достаточно не добавлять тайлы со стенами в границу поиска. Это можно сделать, заставив GameTile.GrowPathTo не возвращать тайлы со стенами. Но путь всё равно должен вырастать по направлению стены, чтобы все тайлы на поле имели путь. Это необходимо, потому что существует возможность того, что тайл с врагами внезапно превратится в стену.
 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;
 }

Чтобы гарантировать, что у всех тайлов есть путь, GameBoard.FindPaths должен проверять это после завершения поиска. Если это не так, то состояние поля является неверным и нужно вернуть false. Обновлять визуализацию пути для неверных состояний не нужно, потому что поле вернётся к предыдущему состоянию.
 bool FindPaths () {
  …

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


Стены влияют на пути.

Чтобы убедиться, что у стен и в самом деле есть правильные пути, нужно сделать кубы полупрозрачными.

Прозрачные стены.

Учтите, что требование правильности всех путей не позволяет оградить стенами часть поля, в котором нет конечной точки. Мы можем разделить карту, но только если в каждой части есть хотя бы одна конечная точка. Кроме того, каждая стена должна быть соседней с пустым тайлом или конечной точкой, в противном случае она не сможет сама иметь путь. Например, невозможно сделать сплошной блок из стен размером 3×3.

Скрываем пути


Визуализация путей позволяет нам увидеть, как работает поиск пути и убедиться, что он и в самом деле верен. Но её не нужно показывать игроку, или по крайней мере необязательно. Поэтому давайте обеспечим возможность отключения стрелок. Это можно сделать, добавив в GameTile общий метод HidePath, который просто отключает его стрелку.
 public void HidePath () {
  arrow.gameObject.SetActive(false);
 }

Состояние отображения путей — это часть состояния поля. Добавим к GameBoard булево поле, по умолчанию равное false, чтобы отслеживать его состояние, а также общее свойство в качестве геттера и сеттера. Сеттер должен показывать или скрывать пути на всех тайлах.
 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();
    }
   }
  }
 }

Теперь метод FindPaths должен показывать обновлённые пути, только если включена визуализация.
 bool FindPaths () {
  …

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

По умолчанию визуализация путей отключена. Отключим стрелку в префабе тайла.

Стрелка префаба по умолчанию неактивна.

Сделаем так, чтобы Game переключал состояние визуализации при нажатии клавиши. Логично было бы использовать клавишу P, но она также является горячей клавишей включения-отключения режима игры в редакторе Unity. В результате визуализация будет переключаться, когда использована горячая клавиша выхода из режима игры, что выглядит не очень красиво. Поэтому давайте используем клавишу V (сокращение от visualization).

Без стрелок.

Отображение сетки


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

Текстура сетки.

Мы не будем добавлять эту текстуру по отдельности к каждому тайлу, а применим её к земле. Но сделаем эту сетку необязательной, как и визуализацию путей. Поэтому добавим в GameBoard поле конфигурации Texture2D и выберем для него текстуру сетки.
 [SerializeField]
 Texture2D gridTexture = default;


Поле с текстурой сетки.

Добавим ещё одно булево поле и свойство для управления состоянием визуализации сетки. В данном случае сеттер должен изменять материал земли, что можно реализовать вызовом GetComponent<MeshRenderer> для земли и получив доступ к свойству material результата. Если сетку нужно отображать, то назначим свойству mainTexture материала текстуру сетки. В противном случае назначим ему null. Учтите, что при изменении текстуры материала будут создаваться дубликаты экземпляра материала, поэтому он становится независимым от ассета материала.
 bool showGrid, showPaths;

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

Сделаем так, чтобы Game переключал визуализацию сетки клавишей G.
 void Update () {
  …
  if (Input.GetKeyDown(KeyCode.G)) {
   board.ShowGrid = !board.ShowGrid;
  }
 }

Кроме того, добавим визуализацию сетки по умолчанию в Awake.
 void Awake () {
  board.Initialize(boardSize, tileContentFactory);
  board.ShowGrid = true;
 }


Неотмасштабированная сетка.

Пока мы получили границу вокруг всего поля. Она соответствует текстуре, но это не то, что нам нужно. Нам нужно отмасштабировать основную текстуру материала, чтобы она соответствовала размеру сетки. Можно это сделать, вызвав метод SetTextureScale материала с именем свойства текстуры (_MainTex) и двухмерным размером. Мы можем использовать непосредственно размер поля, который косвенно преобразуется в значение Vector2.
   if (showGrid) {
    m.mainTexture = gridTexture;
    m.SetTextureScale("_MainTex", size);
   }

without

with

Отмасштабированная сетка с отключенной и включённой визуализацией путей.

Итак, на данном этапе мы получили функционирующее поле для тайловой игры жанра tower defense. В следующем туториале мы добавим врагов.

Репозиторий

PDF

Комментариев нет:

Отправить комментарий