Это вторая часть туториала, посвящённого простой игре в жанре tower defense. В ней рассматривается процесс создания врагов и их движения к ближайшей конечной точке. враги
- Ссылка оригинал https://catlikecoding.com/unity/tutorials/tower-defense/
- Ссылка на перевод https://habr.com/ru/post/452756/
- Автор оригинала: Jasper Flick
- Размещение точек создания врагов.
- Появление врагов и их движение по полю.
- Создание плавного движения с постоянной скоростью.
- Изменение размера, скорости и размещения врагов.
Это вторая часть туториала, посвящённого простой игре в жанре tower defense. В ней рассматривается процесс создания врагов и их движения к ближайшей конечной точке.
Данный туториал выполнен в Unity 2018.3.0f2.
Враги на пути к конечной точке.
Точки создания (спауна) врагов
Прежде чем начать создавать врагов, нам нужно решить, где разместить их на поле. Для этого мы создадим точки спауна.
Содержимое тайлов
Точка спауна — это ещё один тип содержимого тайлов, поэтому добавим запись для него в
GameTileContentType
.А затем создадим префаб, чтобы визуализировать его. Нам вполне подойдёт дубликат префаба начальной точки, просто изменим его тип содержимого и дадим ему другой материал. Я сделал его оранжевым.
Конфигурация точек спауна.
Добавим поддержку точек спауна в фабрику содержимого и дадим ему ссылку на префаб.
Фабрика с поддержкой точек спауна.
Включение и отключение точек спауна
Метод для переключения состояния точки спауна, как и другие методы переключения, мы добавим в
GameBoard
. Но точки спауна не влияют на поиск пути, поэтому после изменения нам не нужно искать новые пути.Игра имеет смысл только если у нас есть враги, а для них необходимы точки спауна. Поэтому поле игры должно содержать хотя бы одну точку спауна. Также нам потребуется доступ к точкам спауна в дальнейшем, когда мы будем добавлять врагов, поэтому давайте воспользуемся списком, чтобы отслеживать все тайлы с этими точками. Будем обновлять список при переключении состояния точки спауна и предотвращать удаление последней точки спауна.
Метод
Initialize
теперь должен задать точку спауна, чтобы создать исходное правильное состояние поля. Давайте просто включим первый тайл, который находится в левом нижнем углу.Мы сделаем так, чтобы теперь альтернативное касание переключало состояние точек спауна, но при зажатом левом Shift (нажатие клавиши проверяется методом
Input.GetKey
) будет переключаться состояние конечной точкиПоле с точками спауна.
Получение доступа к точкам спауна
Поле занимается всеми своими тайлами, но враги — это не его ответственность. Мы сделаем так, чтобы можно было получать доступ к его точкам спауна через общий метод
GetSpawnPoint
с параметром-индексом.Чтобы знать, какие индексы верны, необходима информация о количестве точек спауна, поэтому сделаем её общей с помощью общего свойства-геттера.
Спаунинг врагов
Спаунинг врага чем-то похож на создание содержимого тайла. Мы создаём через фабрику экземпляр префаба, который затем помещаем на поле.
Фабрики
Мы создадим для врагов фабрику, которая будет помещать всё создаваемое ею на собственную сцену. Этот функционал является общим с той фабрикой, которая у нас уже есть, поэтому давайте поместим код для него в общий базовый класс
GameObjectFactory
. Нам будет достаточно одного метода CreateGameObjectInstance
с общим параметром префаба, который создаёт и возвращает экземпляр, а также занимается управлением всей сценой. Сделаем метод protected
, то есть он будет доступен только классу и всем типам, которые от него наследуют. Это всё, что делает класс, он не предназначен для использования в качестве полнофункциональной фабрики. Поэтому пометим его как abstract
, что не позволит создавать экземпляры его объектов.Изменим
GameTileContentFactory
так, чтобы он наследовал этот тип фабрики и использовал CreateGameObjectInstance
в своём методе Get
, а затем уберём из него код управления сценой.После этого создадим новый тип
EnemyFactory
, который создаёт экземпляр одного префаба Enemy
с помощью метода Get
вместе с сопровождающим методом Reclaim
.Новый тип
Enemy
изначально должен только отслеживать свою исходную фабрику.Префаб
Врагам нужна визуализация, которая может быть любой — робот, паук, призрак, что-то более простое, например, куб, который мы и используем. Но в общем случае враг имеет 3D-модель любой сложности. Чтобы обеспечить её удобную поддержку, мы воспользуемся для иерархии префаба врага корневым объектом, к которому прикреплён только компонент
Enemy
.Корень префаба.
Создадим этому объекту единственный дочерний элемент, который будет корнем модели. Он должен иметь единичные значения Transform.
Корень модели.
Задача этого корня модели — расположить 3D-модель относительно локальной точки начала координат врага, чтобы он считал её опорной точкой, над которой враг стоит или висит. В нашем случае модель будет стандартным кубом половинного размера, которому я придам тёмно-синий цвет. Сделаем его дочерним элементом корня модели и присвоим позиции по Y значение 0.25, чтобы он стоял на земле.
Модель куба.
Таким образом, префаб врага состоит из трёх вложенных объектов: корня префаба, корня модели и куба. Может показаться, что для простого куба это перебор, но такая система позволяет перемещать и анимировать любого врага, не беспокоясь о его особенностях.
Иерархия префаба врага.
Создадим фабрику врагов и назначим ей префаб.
Ассет фабрики.
Размещение врагов на поле
Чтобы поместить врагов на поле,
Game
должен получит ссылку на фабрику врагов. Так как нам нужно много врагов, добавим опцию конфигурации для настройки скорости спаунинга, выражаемую в количестве врагов за секунду. Приемлемым кажется интервал 0.1–10 со значением 1 по умолчанию.Game с фабрикой врагов и скоростью спаунинга 4.
Progress спаунинга будем отслеживать в
Update
, увеличивая его на скорость, умноженную на дельту времени. Если величина prggress превышает 1, то выполняем его декремент и спауним врага с помощью нового метода SpawnEnemy
. Продолжаем это делать, пока progress превышает 1 на случай, если скорость слишком высока и время кадра оказалось очень длинным, чтобы одновременно не создалось несколько врагов.
Разве не нужно обновлять progress в FixedUpdate?
Пусть
SpawnEnemy
получит случайную точку спауна с поля и создаст в этом тайле врага. Мы дадим Enemy
метод SpawnOn
, чтобы он правильно себя спозицинировал.Пока всё, что должен делать
SpawnOn
— это задавать собственную позицию равной центру тайла. Так как модель префаба расположена правильно, куб-враг окажется поверх этого тайла.Враги появляются в точках спауна.
Перемещение врагов
После появления врага он должен начать двигаться по пути к ближайшей конечной точке. Чтобы добиться этого, нужно анимировать врагов. Мы начнём с простого плавного скольжения от тайла к тайлу, а затем сделаем их движение более сложным.
Коллекция врагов
Для обновления состояния врагов мы воспользуемся тем же подходом, который исоплзовали в серии туториалов Object Management. Добавим
Enemy
общий метод GameUpdate
, возвращающий информацию о том, жив ли он, что на данном этапе всегда будет истиной. Пока просто заставим его двигаться вперёд согласно дельте времени.Кроме того, нам нужно вести список живых врагов и всех их обновлять, удаляя из списка мёртвых врагов. Мы можем поместить весь этот код в
Game
, но давайте вместо этого изолируем его и создадим тип EnemyCollection
. Это сериализуемый класс, который ни от чего не наследует. Дадим ему общий метод для добавления врага и ещё один метод для обновления всей коллекции.Теперь
Game
будет достаточно создать всего одну такую коллекцию, в каждом кадре обновлять её и добавлять в неё созданных врагов. Врагов будем обновлять сразу же после возможного спаунинга нового врага, чтобы обновление происходило мгновенно.Враги движутся вперёд.
Движение по пути
Враги уже перемещаются, но пока не следуют по пути. Для этого им нужно знать, куда двигаться дальше. Поэтому дадим
GameTile
общее свойство-геттер для получения следующего тайла на пути.Зная тайл, из которого нужно выйти, и тайл, в который нужно попасть, враги могут определить начальную и конечную точки для перемещения на один тайл. Враг может интерполировать положение между этими двумя точками, отслеживая своё перемещение. После завершения перемещения этот процесс повторяется для следующего тайла. Но пути могут в любой момент измениться. Вместо того, чтобы определять, куда двигаться дальше в процессе движения, мы просто продолжает двигаться вдоль запланированного маршрута и проверяем его, достигнув следующего тайла.
Пусть
Enemy
отслеживает оба тайла, чтобы на него не влияло изменение пути. Также он будет отслеживать позиции, чтобы нам не приходилось получать их в каждом кадре, и отслеживать процесс перемещения.Инициализируем эти поля в
SpawnOn
. Первая точка — это тайл, из которого движется враг, а конечная точка — следующий тайл на пути. Это предполагает, что существует следующий тайл, если только враг не был создан в конечной точке, что должно быть невозможным. Тогда мы кэшируем позиции тайлов и обнулим progress. Позицию врага нам задавать здесь не нужно, потому что его метод GameUpdate
вызывается в том же кадре.Инкремент progress будем выполнять в
GameUpdate
. Прибавим неизменную дельту времени, чтобы враги двигались со скоростью один тайл в секунду. Когда движение (progress) завершено, смещаем данные так, чтобы To
становилось значение From
, а новым To
— следующий тайл на пути. Затем выполняем декремент progress. Когда данные становятся актуальными, интерполируем позицию врага между From
и To
. Так как интерполятором является progress, его значение обязательно находится в интервале от 0 и 1, моэтому мы можем использовать sVector3.LerpUnclamped
.Это заставляет врагов следовать по пути, но не будет действовать при достижении конечной точки. Поэтому прежде чем изменять позиции
From
и To
, нужно сравнивать следующий тайл на пути с null
. Если это так, то мы достигли конечной точки и враг закончил движение. Выполняем для него Reclaim и возвращаем false
.Враги следуют по кратчайшему пути.
Враги теперь движутся от центра одного тайла к другому. Стоит учесть, что они меняют своё состояние движения только в центрах тайлов, поэтому не могут мгновенно реагировать на изменения на поле. Это означает, что иногда враги будут двигаться сквозь только что поставленные стены. Как только они начали двигаться в сторону ячейки, их ничто не остановит. Именно поэтому стенам тоже нужны действительные пути.
Враги реагируют на изменение пути.
Движение от края к краю
Движение между центрами тайлов и резкая смена направлений выглядит нормально для абстрактной игры, в которой врагами являются подвижные кубики, но обычно красивее выглядит плавное движение. Первый шаг к его реализации — движение не по центрам, а по краям тайлов.
Точку края между соседними тайлами можно найти усреднением их позиций. Вместо того, чтобы вычислять её на каждом шагу для каждого врага, мы будем вычислять её только при изменении пути в
GameTile.GrowPathTo
. Сделаем её доступной с помощью свойства ExitPoint
.Единственным особым случаем является конечная ячейка, точкой выхода которой будет её центр.
Изменим
Enemy
таким образом, чтобы он использовал точки выхода, а не центры тайлов.Враги движутся между краями.
Побочный эффект этого изменения заключается в том, что когда враги поворачивают вследствие изменения пути, то они на секунду остаются неподвижными.
При повороте враги останавливаются.
Ориентация
Хотя враги движутся по путям, пока они не меняют своей ориентации. Чтобы они могли смотреть в сторону движения, им необходимо знать направление пути, по которому они следуют. Это мы тоже будем определять во время поиска путей, чтобы этого не приходилось делать врагам.
У нас есть четыре направления: север, восток, юг и запад. Зададим для них перечисление.
Затем дадим
GameTile
свойство, чтобы хранить направление его пути.Добавим параметр направления к
GrowTo
, который задаёт свойство. Так как мы выращиваем путь с конца в начало, направление будет противоположным к тому, откуда мы выращиваем путь.Нам нужно преобразовать направления в повороты, выраженные в виде кватернионов. Было бы удобно, если бы мы просто могли вызывать
GetRotation
для направления, поэтому давайте сделаем это, создав расширяющий метод. Добавим общий статический метод DirectionExtensions
, дадим ему массив для кэширования необходимых кватернионов, а также метод GetRotation
для возврата соответствующего значения направления. В данном случае имеет смысл поместить расширяющий класс в тот же файл, что и тип перечисления.
Что такое расширяющий метод (extension method)?
Теперь мы можем поворачивать
Enemy
при спаунинге и каждый раз, когда мы входим в новый тайл. После обновления данных тайл From
даёт нам направление.Смена направления
Вместо того, чтобы мгновенно менять направление, лучше интерполировать значения между поворотами, аналогично тому, как мы интерполировали между позициями. Чтобы перейти от одной ориентации к другой, нам нужно знать смену направления, которую необходимо выполнить: без поворота, поворот вправо, поворот влево или поворот назад. Добавим для этого перечисление, которое опять же можно разместить в том же файле, что и
Direction
, потому что они малы и тесно связаны.Добавим ещё один расширяющий метод, на этот раз
GetDirectionChangeTo
, который возвращает смену направления от текущего направления к следующему. Если направления совпадают, то смены нет. Если следующее на один больше текущего, то это поворот направо. Но так как направления повторяются такая же ситуация будет, когда следующее на три меньше текущего. С поворотом налево будет то же самое, только сложение и вычитание поменяются местами. Единственный оставшийся случай — это поворот назад.Мы совершаем поворот только в одном измерении, поэтому нам достаточно будет линейной интерполяции углов. Добавим ещё один расширяющий метод, который получает угол направления в градусах.
Теперь
Enemy
придётся отслеживать направление, смену направления и углы, между которыми нужно выполнять интерполяцию.SpawnOn
становится сложнее, поэтому давайте переместим код подготовки состояния в другой метод. Мы назначим исходное состояние врага как вводное состояние, поэтому назовём его PrepareIntro
. В этом состоянии враг перемещается от центра к краю своего начального тайла, поэтому смена направления не происходит. Углы From
и To
одинаковы.На этом этапе мы создаём нечто наподобие небольшого конечного автомата. Чтобы не усложнять
GameUpdate
, переместим код изменения состояния в новый метод PrepareNextState
. Оставим только изменения тайлов From
и To
, потому что мы используем их здесь для проверки того, закончил ли враг путь.При переходе в новое состояние всегда нужно изменять позиции, находить смену направления, обновлять текущее направление и смещать угол
To
к From
. Поворот мы больше не задаём.Другие действия зависят от смены направления. Давайте добавим метод для каждого варианта. В случае, если мы движемся вперёд, то угол
To
совпадает с направлением пути текущей ячейки. Кроме того, нам нужно задать поворот, чтобы враг смотрел прямо вперёд.В случае поворота мы не поворачиваемся мгновенно. Нам нужно интерполировать к другому углу: на 90° больше для поворота вправо, на 90° меньше для поворота влево, и на 180° больше для поворота назад. Чтобы избежать поворота не в том направлении из-за смены значений углов от 359° к 0°, угол
To
должен указываться относительно текущего направления. Нам не нужно волноваться, что угол станет меньше 0° или больше 360°, потому что Quaternion.Euler
может справиться с этим.В конце
PrepareNextState
мы можем использовать switch
для смены направления, чтобы решить, какой из четырёх методов вызывать.Теперь в конце
GameUpdate
нам нужно проверять, произошла ли смена направления. Если да, то выполнить интерполяцию между двумя углами и задать поворот.Враги поворачиваются.
Движение по кривой
Мы можем улучшить движение, заставив врагов при повороте двигаться по кривой. Вместо того, чтобы ходить от края к краю тайлов, пусть ходят по четверти окружности. Центр этой окружности лежит в углу, общем для тайлов
From
и To
, на том же самом краю, по которому враг вошёл на тайл From
.Вращение на четверть круга для поворота вправо.
Мы можем реализовать это, двигая врага по дуге с помощью тригонометрии, в то же время поворачивая его. Но это можно и упростить, использовав только поворот, временно переместив локальное начало координат врага в центр круга. Чтобы сделать это, нам нужно изменить позицию модели врага, поэтому дадим
Enemy
ссылку на эту модель, доступную через поле конфигурации.Enemy со ссылкой на модель.
При подготовке к движению вперёд или повороту назад модель должна перемещаться в стандартное положение, в локальное начало координат врага. В противном случае модель нужно смещать на половину единицы измерения — радиус окружности поворота, вдаль от точки поворота.
Теперь самого врага нужно переместить в точку поворота. Для этого его нужно тоже переместить на половину единицы измерения, но точное смещение зависит от направления. Давайте добавим в
Direction
для этого вспомогательный расширяющий метод GetHalfVector
.Прибавляем соответствующий вектор при повороте вправо или влево.
А при повороте назад позиция должна быть обычной начальной точкой.
Кроме того, мы можем при вычислении точки выхода использовать в
GameTile.GrowPathTo
половину вектора, чтобы нам не нужен был доступ к двум позициям тайлов.Теперь при смене направления мы не должны интерполировать позицию в
Enemy.GameUpdate
, потому что движением занимается поворот.Враги плавно огибают углы.
Постоянная скорость
До этого момента скорость врагов всегда была равна одному тайлу в секунду, вне зависимости от того, как они движутся внутри тайла. Но покрываемое ими расстояние зависит от их состояния, поэтому их скорость, выражаемая в единицах в секунду, изменяется. Чтобы эта скорость была постоянной, нам нужно изменять скорость progress в зависимости от состояния. Поэтому добавим поле множителя progress и используем его для масштабирования дельты в
GameUpdate
.Но если progress меняется в зависимости от состояния, оставшееся значение progress невозможно напрямую использовать для следующего состояния. Поэтому перед подготовкой к новому состоянию нам нужно нормализовать progress и применить новый множитель уже в новом состоянии.
Движение вперёд не требует изменений, поэтому использует множитель 1. При повороте вправо или влево враг проходит четверть окружности с радиусом ½, поэтому покрываемое расстояние равно ¼π.
progress
равен единице, разделённой на эту величину. Поворот назад не должен занимать слишком много времени, поэтому удвоим progress, чтобы он занимал полсекунды. Наконец, вводное движение покрывает только половину тайла, поэтому для сохранения постоянной скорости его progress тоже нужно удвоить.
Почему расстояние равно 1/4*pi?
Завершающее состояние
Так как у нас есть вводное состояние, давайте добавим и завершающее. В данный момент враги исчезают сразу после достижения конечной точки, но давайте отложим их исчезновение, пока они не достигнут центра конечного тайла. Создадим для этого метод
PrepareOutro
, зададим движение вперёд, но только до центра тайла с удвоенным progress для сохранения постоянной скорости.Чтобы
GameUpdate
не уничтожал врага слишком рано, удалим из него сдвиг тайлов. Им теперь займётся PrepareNextState
. Таким образом, проверка на null
вернёт true
только после конца завершающего состояния.В
PrepareNextState
мы начнём со сдвига тайлов. Затем после задания позиции From
, но перед заданием позиции To
будем проверять, равен ли тайл To
значению null
. Если да, то подготавливаем завершающее состояние и пропускаем остальную часть метода.Враги с постоянной скоростью и завершающим состоянием.
Вариативность врагов
У нас есть поток врагов, и все они являются одинаковым кубом, движущимся с одинаковой скоростью. Получившийся результат больше походит на длинную змею, чем на отдельных врагов. Давайте сделаем их более отличающимися, рандомизировав их размер, смещение и скорость.
Интервал значений Float
Мы будем изменять параметры врагов, случайным образом выбирая их характеристики из интервала значений. Здесь будет полезна структура
FloatRange
, которую мы создали в статье Object Management, Configuring Shapes, поэтому давайте её скопируем. Единственными изменениями стали добавление конструктора с одним параметром и открытие доступа к минимуму и максимуму с помощью readonly-свойств, чтобы интервал был неизменяемым.Также скопируем заданный ему атрибут, чтобы ограничить его интервал.
Нам нужна только визуализация ползунка, поэтому скопируем
FloatRangeSliderDrawer
в папку Editor.Масштаб модели
Начнём мы с изменения масштаба врага. Добавим в
EnemyFactory
опцию настройки масштаба. Интервал масштабов не должен быть слишком большим, но достаточным для создания миниатюрных и гигантски разновидностей врагов. Что-нибудь в пределах 0.5–2 со стандартным значением 1. Будем выбирать случайный масштаб в этом интервале в Get
и передавать его врагу через новый метод Initialize
.Метод
Enemy.Initialize
просто задаёт одинаковый по всем измерениям масштаб его модели.Интервал масштабов от 0.5 до 1.5.
Смещение пути
Чтобы ещё сильнее разрушить однородность потока врагов, мы можем изменить их относительную позицию внутри тайлов. Они движутся вперёд, поэтому смещение в этом направлении всего лишь изменяет тайминг их движения, что не очень заметно. Поэтому мы будем смещать их вбок, в сторону от идеального пути, проходящего через центры тайлов. Добавим в
EnemyFactory
интервал смещений пути и будем передавать случайное смещение методу Initialize
. Смещение может быть отрицательным или положительным, но никогда не больше ½, потому что это сдвинуло бы врага на соседний тайл. Кроме того, мы не хотим, чтобы враги выходили за пределы тайлов, по которым идут, поэтому на самом деле интервал будет меньше, например, 0.4, однако истинные пределы зависят от размера врага.Так как смещение пути влияет на проходимый путь,
Enemy
необходимо его отслеживать.При движении ровно прямо (во время вводного, завершающего или обычного движения вперёд) мы просто применяем смещение непосредственно к модели. То же самое происходит и при повороте назад. При правом или левом повороте мы уже смещаем модель, которая становится относительной к смещению пути.
Так как смещение пути при повороте изменяет радиус, нам необходимо изменить процесс вычисления множителя progress. Смещение пути должно вычитаться из ½, чтобы получить радиус поворота вправо, и прибавляться в случае поворота влево.
Также мы получаем радиус поворота при повороте на 180°. В этом случае мы покрываем половину окружности радиусом, равным смещению пути, поэтому расстояние равно π, умноженному на смещение. Однако это не срабатывает, когда смещение равно нулю, а при малых смещениях повороты получаются слишком быстрыми. Чтобы избежать мгновенных поворотов, мы можем принудительно задать минимальный радиус для вычисления скорости, допустим, 0.2.
Смещение пути в интервале −0.25–0.25.
Заметьте, что теперь враги никогда не меняют своё относительное смещение пути, даже при повороте. Поэтому общая длина пути у каждого врага своя.
Чтобы враги не выходили на соседние тайлы, надо также учитывать их максимальный возможный масштаб. Я просто ограничил размер максимальным значением 1, поэтому максимальное допустимое смещение для куба равно 0.25. Если бы максимальный размер был равен 1.5, то максимум смещения надо было снизить до 0.125.
Скорость
Последнее, что мы рандомизируем — это скорость врагов. Добавим ещё один интервал для неё в
EnemyFactory
и будем передавать значение созданному экземпляру врага. Сделаем его вторым аргументом метода Initialize
. Враги не должны быть слишком медленными или быстрыми, чтобы игра не стала тривиально простой или невозможно трудной. Давайте ограничим интервал в пределах 0.2–5. Скорость выражается в единицах в секунду, что соответствует тайлам в секунду только при движении вперёд.Теперь
Enemy
должен отслеживать и скорость.Когда мы не задавали скорость явно, то просто всегда использовали значение 1. Теперь нам просто создать зависимость множителя progress от скорости.
Скорость в интервале 0.75–1.25.
Итак, мы получили красивый поток врагов, движущихся к конечной точке. В следующем туториале мы научимся с ними бороться. Хотите знать, когда он выйдет? Следите за моей страницей на Patreon!
репозиторий
Статья в PDF
Комментариев нет:
Отправить комментарий