Автор оригинала: Jasper Flick Разработка игр, Unity,
Дизайн игр
Перевод
Дизайн игр
Перевод
[Первая, вторая, третья и четвёртая части туториала]
Туториал создавался в Unity 2018.4.6f1.
Становится довольно уютно.
Не очень интересно каждый раз создавать одинаковый синий куб. Первым шагом к поддержке более интересных геймплейных сценариев станет поддержка нескольких типов врагов.
Существует много способов сделать врагов уникальными, но мы не будем усложнять: классифицируем их как мелких, средних и крупных. Чтобы пометить их, создадим перечисление
Изменим
Давайте также сделаем настраиваемым для каждого врага здоровье, ведь логично, что у крупных врагов его больше, чем у мелких.
Добавим к
Добавим в
Вы можете сами выбирать, каким будет дизайн трёх врагов, но в туториале я буду стремиться к максимальной простоте. Я продублировал исходный префаб врага и использовал его для всех трёх размеров, меняя только материал: жёлтый для мелкого, синий для среднего и красный для крупного. Я не менял масштаб префаба куба, а использовал для задания размеров конфигурацию масштаба фабрики. Также в зависимости от размера я увеличил им здоровье и уменьшил скорость.
Фабрика для врагов-кубов трёх размеров.
Быстрее всего сделать так, чтобы все три типа появились в игре, изменив
Враги разных типов.
Теперь фабрика врагов задаёт множество из трёх врагов. Имеющаяся фабрика создаёт кубы трёх размеров, но ничто не мешает нам сделать ещё одну фабрику, создающую что-то иное, например сферы трёх размеров. Мы можем менять создаваемых врагов, назначая в игре другую фабрику, переключаясь таким образом на другую тему.
Сферические враги.
Вторым этапом по созданию геймплейных сценариев будет отказ от порождения врагов с постоянной частотой. Враги должны создаваться последовательными волнами, пока не закончится сценарий или игрок не проиграет.
Одна волна врагов состоит из группы создаваемых один за другим врагов до завершения волны. Волна может содержать разные типы врагов, а задержка между их созданием может варьироваться. Чтобы не усложнять реализацию, мы начнём с простой последовательности порождения, создающей одинаковый тип врагов с постоянной частотой. Тогда волна будет просто списком таких последовательностей.
Для конфигурирования каждой последовательности создадим класс
Волна — это простой массив последовательностей создания врагов. Создадим для неё тип ассета
Теперь мы можем создавать волны врагов. Например, я создал волну, порождающую группу кубических врагов, начиная с десяти маленьких, с частотой по два в секунду. За ними идут пять средних, создаваемых раз в секунду, и, наконец, один большой враг с паузой в пять секунд.
Волна увеличивающихся в размерах кубов.
Геймплейный сценарий создаётся из последовательности волн. Создадим для этого тип ассета
Например, я создал сценарий с двумя волнами мелких-средних-крупных врагов (МСК), сначала с кубами, потом со сферами.
Сценарий с двумя волнами МСК.
Для создания сценариев используются типы ассетов, но поскольку это ассеты, они должны содержать данные, которые во время игры не меняются. Однако для продвижения по сценарию нам каким-то образом нужно отслеживать их состояние. Один из способов заключается в дублировании используемого в игре ассета, чтобы дубликат отслеживал его состояние. Но нам не требуется дублировать ассет целиком, достаточно состояния и ссылки на ассет. Поэтому давайте создадим отдельный класс
Вложенный тип состояния, ссылающийся на свою последовательность.
Когда мы хотим начать продвижение по последовательности, нам требуется для этого новый экземпляр состояния. Добавим последовательности метод
Чтобы состояние выживало после горячих перезагрузок, нужно сделать его сериализуемым.
Недостаток такого подхода в том, что каждый раз при запуске последовательности нам требуется создавать новый объект состояния. Мы можем избежать выделения памяти, сделав его вместо класса структурой. Это нормально, пока состояние остаётся небольшим. Просто учитывайте, что состояние — это тип-значение. При его передаче оно копируется, поэтому отслеживайте его в одном месте.
Состояние последовательности состоит всего из двух аспектов: количества порождаемых врагов и продвижения времени паузы. Добавим метод
Состояние содержит только необходимые данные.
Продвижение должно продолжаться, пока не будет создано нужное количество врагов и не закончится пауза. В этот момент
Чтобы последовательности могли порождать врагов, нам нужно преобразовать
Так как сам
Мы будем вызывать
Примерим тот же подход к продвижению по последовательности, что и при продвижении по целой волне. Дадим
Состояние волны, содержащее состояние последовательности.
Добавим также
Добавим
Так как мы находимся на верхнем уровне, метод
Для воспроизведения сценария
Теперь сконфигурированный сценарий будет запускаться при начале игры. Продвижение по нему будет выполняться до завершения, а после этого ничего не происходит.
Две волны, ускоренные в 10 раз.
Мы можем воспроизвести один сценарий, но после его завершения новые враги не появятся. Чтобы игра продолжалась, нам нужно сделать так, чтобы имелась возможность начать новый сценарий, или вручную, или потому что игрок проиграл/выиграл. Можно также реализовать выбор из нескольких сценариев, но в этом туториале мы его не будем рассматривать.
В идеале нам нужна возможность начать новую игру в любой момент времени. Для этого потребуется сбросить текущее состояние всей игры, то есть нам придётся сбрасывать множество объектов. Для начала добавим в
Это предполагает, что утилизировать можно все поведения, но пока это не так. Чтобы это заработало, добавим в
Метод
У
Теперь мы можем добавить в
Будем вызывать этот метод в
Цель игры — победить всех врагов, прежде чем определённое их число доберётся до конечной точки. Количество врагов, необходимых для срабатывания условия поражения, зависит от исходного здоровья игрока, для которого мы добавим в
Изначально игрок имеет 10 единиц здоровья.
В случае Awake или начала новой игры присваиваем текущему здоровью игрока исходное значение.
Добавим публичный статический метод
Вызовем этот метод в
Теперь мы можем проверять в
Альтернативой поражению является победа, которая достигается при завершении сценария, если игрок всё ещё жив. То есть когда результат
Однако при этом победа настанет после завершения последней паузы, даже если на поле всё ещё есть враги. Нам нужно отложить победу на момент, когда все враги пропадут, что можно реализовать проверкой того, пуста ли коллекция врагов. Мы допустим, что у неё есть свойство
Добавим нужное свойство в
Давайте также реализуем возможность управления временем, это поможет в тестировании и часто является геймплейной функцией. Для начала пусть
Во-вторых, добавим в
Скорость игры.
Если пауза не включена и масштабу времени не присвоено значение паузы, сделаем его равным скорости игры. Также при снятии паузы используем вместо единицы скорость игры.
В некоторых сценариях может потребоваться пройти все волны несколько раз. Можно реализовать поддержку такой функции, сделав возможным повторение сценариев цикличеким проходом по всем волнам несколько раз. Можно ещё больше усовершенствовать эту функцию, например, включив возможность повтора только последней волны, но в данном туториале мы будем просто повторять весь сценарий.
Добавим в
Сценарий с двумя циклами.
Теперь
В
Если игроку удалось победить цикл один раз, то он сможет без проблем победить его снова. Чтобы сценарий оставался сложным, мы должны повысить сложность. Проще всего это сделать, снижая в последующих циклах все величины пауз между созданием врагов. Тогда враги станут появляться быстрее и неизбежно победят игрока в сценарии выживания.
Добавим в
Теперь нужно добавить масштаб времени и в
Три цикла с увеличивающейся скоростью создания врагов; ускорено в десять раз.
Хотите получать информацию о выходе новых туториалов? Следите за моей страницей на Patreon!
Репозиторий
Статья в PDF
- Поддержка врагов малого, среднего и крупного размеров.
- Создание игровых сценариев со множественными волнами врагов.
- Разделение конфигурации ассетов и геймплейного состояния.
- Запуск, пауза, победа, поражение и ускорение игры.
- Создание бесконечно повторяющихся сценариев.
Туториал создавался в Unity 2018.4.6f1.
Становится довольно уютно.
Больше врагов
Не очень интересно каждый раз создавать одинаковый синий куб. Первым шагом к поддержке более интересных геймплейных сценариев станет поддержка нескольких типов врагов.
Конфигурации врагов
Существует много способов сделать врагов уникальными, но мы не будем усложнять: классифицируем их как мелких, средних и крупных. Чтобы пометить их, создадим перечисление
EnemyType
.Изменим
EnemyFactory
так, чтобы она поддерживала вместо одного все эти три типа врагов. Для всех трёх врагов нужны одинаковые поля конфигурации, поэтому добавим вложенный класс EnemyConfig
, содержащий их все, а затем добавим три поля конфигурации этого типа к фабрике. Так как этот класс применяется только для конфигурации и мы нигде больше не будем его использовать, то можно просто сделать его поля публичными, чтобы фабрика могла получать к ним доступ. Сам EnemyConfig
не обязан быть публичным.Давайте также сделаем настраиваемым для каждого врага здоровье, ведь логично, что у крупных врагов его больше, чем у мелких.
Добавим к
Get
параметр типа, чтобы было можно получать конкретный тип врага, и типом по умолчанию будет средний. Воспользуемся типом для получения правильной конфигурации, для чего пригодится отдельный метод, а затем создадим и инициализируем врага как и раньше, только с добавленным аргументом здоровья.Добавим в
Enemy.Initialize
обязательный параметр health и используем его для задания здоровья вместо того, чтобы определять его по размеру врага.Создаём дизайн разных врагов
Вы можете сами выбирать, каким будет дизайн трёх врагов, но в туториале я буду стремиться к максимальной простоте. Я продублировал исходный префаб врага и использовал его для всех трёх размеров, меняя только материал: жёлтый для мелкого, синий для среднего и красный для крупного. Я не менял масштаб префаба куба, а использовал для задания размеров конфигурацию масштаба фабрики. Также в зависимости от размера я увеличил им здоровье и уменьшил скорость.
Фабрика для врагов-кубов трёх размеров.
Быстрее всего сделать так, чтобы все три типа появились в игре, изменив
Game.SpawnEnemy
, чтобы он получал вместо среднего случайный тип врага.Враги разных типов.
Несколько фабрик
Теперь фабрика врагов задаёт множество из трёх врагов. Имеющаяся фабрика создаёт кубы трёх размеров, но ничто не мешает нам сделать ещё одну фабрику, создающую что-то иное, например сферы трёх размеров. Мы можем менять создаваемых врагов, назначая в игре другую фабрику, переключаясь таким образом на другую тему.
Сферические враги.
Волны врагов
Вторым этапом по созданию геймплейных сценариев будет отказ от порождения врагов с постоянной частотой. Враги должны создаваться последовательными волнами, пока не закончится сценарий или игрок не проиграет.
Последовательности создания
Одна волна врагов состоит из группы создаваемых один за другим врагов до завершения волны. Волна может содержать разные типы врагов, а задержка между их созданием может варьироваться. Чтобы не усложнять реализацию, мы начнём с простой последовательности порождения, создающей одинаковый тип врагов с постоянной частотой. Тогда волна будет просто списком таких последовательностей.
Для конфигурирования каждой последовательности создадим класс
EnemySpawnSequence
. Так как он довольно сложен, поместим его в отдельный файл. Последовательность должна знать, какую фабрику ей использовать, какой тип врага создавать, их количество и частоту. Чтобы упростить конфигурирование, мы сделаем последний параметр паузой, определяющей, какое время должно пройти до создания следующего врага. Заметьте, что такой подход позволяет использовать в волне несколько фабрик врагов.Волны
Волна — это простой массив последовательностей создания врагов. Создадим для неё тип ассета
EnemyWave
, который начинается с одной стандартной последовательности.Теперь мы можем создавать волны врагов. Например, я создал волну, порождающую группу кубических врагов, начиная с десяти маленьких, с частотой по два в секунду. За ними идут пять средних, создаваемых раз в секунду, и, наконец, один большой враг с паузой в пять секунд.
Волна увеличивающихся в размерах кубов.
Можно ли добавить между последовательностями задержку?
Сценарии
Геймплейный сценарий создаётся из последовательности волн. Создадим для этого тип ассета
GameScenario
с одним массивом волн, а затем используем его для изготовления сценария.Например, я создал сценарий с двумя волнами мелких-средних-крупных врагов (МСК), сначала с кубами, потом со сферами.
Сценарий с двумя волнами МСК.
Движение по последовательности
Для создания сценариев используются типы ассетов, но поскольку это ассеты, они должны содержать данные, которые во время игры не меняются. Однако для продвижения по сценарию нам каким-то образом нужно отслеживать их состояние. Один из способов заключается в дублировании используемого в игре ассета, чтобы дубликат отслеживал его состояние. Но нам не требуется дублировать ассет целиком, достаточно состояния и ссылки на ассет. Поэтому давайте создадим отдельный класс
State
, сначала для EnemySpawnSequence
. Так как он применяется только к последовательности, сделаем его вложенным. Он действителен только когда имеет ссылку на последовательность, поэтому дадим ему метод-конструктор с параметром-последовательностью.Вложенный тип состояния, ссылающийся на свою последовательность.
Когда мы хотим начать продвижение по последовательности, нам требуется для этого новый экземпляр состояния. Добавим последовательности метод
Begin
, конструирующий и возвращающий состояние. Благодаря этому все, кто будет вызывать Begin
, будут нести ответственность за соответствие состоянию, а сама последовательность будет оставаться не имеющей состояния. Возможно будет даже параллельно продвигаться несколько раз по одной и той же последовательности.Чтобы состояние выживало после горячих перезагрузок, нужно сделать его сериализуемым.
Недостаток такого подхода в том, что каждый раз при запуске последовательности нам требуется создавать новый объект состояния. Мы можем избежать выделения памяти, сделав его вместо класса структурой. Это нормально, пока состояние остаётся небольшим. Просто учитывайте, что состояние — это тип-значение. При его передаче оно копируется, поэтому отслеживайте его в одном месте.
Состояние последовательности состоит всего из двух аспектов: количества порождаемых врагов и продвижения времени паузы. Добавим метод
Progress
, увеличивающий величину паузы на дельту времени, а затем сбрасывающий её при достижении сконфигурированного значения, аналогично тому, как происходит с временем порождения в Game.Update
. Будем выполнять инкремент счёта врагов каждый раз, когда это происходит. Кроме того, значение паузы должно начинаться с максимального значения, чтобы последовательность создавала врагов без паузы в начале.Состояние содержит только необходимые данные.
Можно ли получить доступ к EnemySpawnSequence.cooldown из State?
Продвижение должно продолжаться, пока не будет создано нужное количество врагов и не закончится пауза. В этот момент
Progress
должен сообщать о завершении, но скорее всего мы немного перепрыгнем через величину. Следовательно, в этот момент мы должны вернуть лишнее время, чтобы использовать его в продвижении по следующей последовательности. Чтобы это сработало, нужно превратить дельту времени в параметр. Также нам нужно обозначить то, что мы пока не закончили, и это можно реализовать возвратом отрицательного значения.Создание врагов в любой точке
Чтобы последовательности могли порождать врагов, нам нужно преобразовать
Game.SpawnEnemy
в другой публичный статический метод.Так как сам
Game
больше не будет порождать врагов, мы можем удалить из Update
фабрику врагов, скорость создания, процесс продвижения создания и код создания врагов.Мы будем вызывать
Game.SpawnEnemy
в EnemySpawnSequence.State.Progress
после увеличения счёта врагов.Продвижение по волне
Примерим тот же подход к продвижению по последовательности, что и при продвижении по целой волне. Дадим
EnemyWave
его собственный метод Begin
, возвращающий новый экземпляр вложенной структуры State
. В данном случае состояние содержит индекс волны и состояние активной последовательности, которое мы инициализируем началом первой последовательности.Состояние волны, содержащее состояние последовательности.
Добавим также
EnemyWave.State
метод Progress
, в котором используется тот же подход, что и раньше, то с небольшими изменениями. Начинаем с продвижения по активной последовательности и заменяем дельту времени результатом этого вызова. Пока остаётся время, перемещаемся к следующей последовательности, если она доступа, и выполняем продвижение по ней. Если последовательностей не осталось, то возвращаем оставшееся время; в противном случае возвращаем отрицательное значение.Продвижение по сценарию
Добавим
GameScenario
такую же обработку. В данном случае состояние содержит индекс волны и состояние активной волны.Так как мы находимся на верхнем уровне, метод
Progress
не требует параметра и можно использовать непосредственно Time.deltaTime
. Нам не нужно возвращать оставшееся время, но требуется показывать, завершён ли сценарий. Будем возвращать false
после завершения последней волны и true
, чтобы показать, что сценарий всё ещё активен.Запуск сценария
Для воспроизведения сценария
Game
требуется поле конфигурации сценария и отслеживание его состояния. Мы будем просто запускать сценарий в Awake и выполнять продвижение по нему Update
до обновления состояния остальной части игры.Теперь сконфигурированный сценарий будет запускаться при начале игры. Продвижение по нему будет выполняться до завершения, а после этого ничего не происходит.
Две волны, ускоренные в 10 раз.
Начало и завершение игр
Мы можем воспроизвести один сценарий, но после его завершения новые враги не появятся. Чтобы игра продолжалась, нам нужно сделать так, чтобы имелась возможность начать новый сценарий, или вручную, или потому что игрок проиграл/выиграл. Можно также реализовать выбор из нескольких сценариев, но в этом туториале мы его не будем рассматривать.
Начало новой игры
В идеале нам нужна возможность начать новую игру в любой момент времени. Для этого потребуется сбросить текущее состояние всей игры, то есть нам придётся сбрасывать множество объектов. Для начала добавим в
GameBehaviorCollection
метод Clear
, утилизирующий все его поведения.Это предполагает, что утилизировать можно все поведения, но пока это не так. Чтобы это заработало, добавим в
GameBehavior
абстрактный метод Recycle
.Метод
Recycle
класса WarEntity
должен явным образом переопределять его.У
Enemy
пока нет метода Recycle
, поэтому добавим его. Всё, что он должен делать — заставлять фабрику возвращать его обратно. Затем вызовем Recycle
везде, где мы напрямую выполняем доступ к фабрике.GameBoard
тоже нужно сбросить, поэтому дадим ему метод Clear
, опустошающий все тайлы, сбрасывающий все точки создания и обновляющийся контент, а затем задающий стандартные начальную и конечную точки. Затем вместо повторения кода мы можем вызвать Clear
в конце Initialize
.Теперь мы можем добавить в
Game
метод BeginNewGame
, сбрасывающий врагов, другие объекты и поле, а затем начинающий новый сценарий.Будем вызывать этот метод в
Update
в случае нажатии клавиши B до продвижения по сценарию.Проигрыш
Цель игры — победить всех врагов, прежде чем определённое их число доберётся до конечной точки. Количество врагов, необходимых для срабатывания условия поражения, зависит от исходного здоровья игрока, для которого мы добавим в
Game
поле конфигурации. Так как мы считаем врагов, то будем использовать integer, а не float.Изначально игрок имеет 10 единиц здоровья.
В случае Awake или начала новой игры присваиваем текущему здоровью игрока исходное значение.
Добавим публичный статический метод
EnemyReachedDestination
, чтобы враги могли сообщать Game
, что достигли конечной точки. Когда это происходит, уменьшаем здоровье игрока.Вызовем этот метод в
Enemy.GameUpdate
в подходящий момент времени.Теперь мы можем проверять в
Game.Update
условие поражения. Если здоровье игрока равно или меньше нуля, то срабатывает условие поражения. Мы просто выведем эту информацию в лог и сразу же начнём новую игру до продвижения по сценарию. Но делать мы это будем только при положительном исходном здоровье. Это позволяет нам использовать в качестве начального здоровья 0, благодаря чему проиграть становится невозможно. Так нам удобно будет тестировать сценарии.Победа
Альтернативой поражению является победа, которая достигается при завершении сценария, если игрок всё ещё жив. То есть когда результат
GameScenario.Progess
равен false
, выводим в лог сообщение о победе, начинаем новую игру, и сразу же продвигаемся по ней.Однако при этом победа настанет после завершения последней паузы, даже если на поле всё ещё есть враги. Нам нужно отложить победу на момент, когда все враги пропадут, что можно реализовать проверкой того, пуста ли коллекция врагов. Мы допустим, что у неё есть свойство
IsEmpty
.Добавим нужное свойство в
GameBehaviorCollection
.Контроль времени
Давайте также реализуем возможность управления временем, это поможет в тестировании и часто является геймплейной функцией. Для начала пусть
Game.Update
проверяет нажатие на пробел, и использует это событие для включения/отключения паузы в игре. Это можно сделать, переключая значения Time.timeScale
между нулём и единицей. Это не изменит игровую логику, но заставит все объекты замереть на месте. Или же можно использовать вместо 0 очень малое значение, например 0.01, чтобы создать чрезвычайно замедленное движение.Во-вторых, добавим в
Game
ползунок конфигурации скорости игры, чтобы можно было ускорять время.Скорость игры.
Если пауза не включена и масштабу времени не присвоено значение паузы, сделаем его равным скорости игры. Также при снятии паузы используем вместо единицы скорость игры.
Циклические сценарии
В некоторых сценариях может потребоваться пройти все волны несколько раз. Можно реализовать поддержку такой функции, сделав возможным повторение сценариев цикличеким проходом по всем волнам несколько раз. Можно ещё больше усовершенствовать эту функцию, например, включив возможность повтора только последней волны, но в данном туториале мы будем просто повторять весь сценарий.
Циклическое продвижение по волнам
Добавим в
GameScenario
ползунок конфигурации для задания количества циклов, по умолчанию присвоим ему значение 1. Минимумом сделаем ноль, при этом сценарий будет повторяться бесконечно. Так мы создадим сценарий выживания, в котором нельзя победить, а смысл заключается в том, чтобы проверить, сколько сможет продержаться игрок.Сценарий с двумя циклами.
Теперь
GameScenario.State
должен отслеживать номер цикла.В
Progress
мы будем выполнять после завершения инкремент цикла, и возвращать false
, только если прошли достаточное количество циклов. В противном случае мы сбрасываем индекс волны на ноль и продолжаем движение.Ускорение
Если игроку удалось победить цикл один раз, то он сможет без проблем победить его снова. Чтобы сценарий оставался сложным, мы должны повысить сложность. Проще всего это сделать, снижая в последующих циклах все величины пауз между созданием врагов. Тогда враги станут появляться быстрее и неизбежно победят игрока в сценарии выживания.
Добавим в
GameScenario
ползунок конфигурации для управления ускорением за цикл. Это значение прибавляется к масштабу времени после каждого цикла только для уменьшения пауз. Например, при ускорении 0.5 первый цикл имеет скорость паузы ×1, второй цикл имеет скорость ×1.5, третий ×2, четвёртый ×2.5, и так далее.Теперь нужно добавить масштаб времени и в
GameScenario.State
. Он всегда изначально равен 1 и увеличивается на заданную величину ускорения после каждого цикла. Используем его для масштабирования Time.deltaTime
перед продвижением по волне.Три цикла с увеличивающейся скоростью создания врагов; ускорено в десять раз.
Хотите получать информацию о выходе новых туториалов? Следите за моей страницей на Patreon!
Репозиторий
Статья в PDF
Комментариев нет:
Отправить комментарий