
Реализация системы управления волнами для создания захватывающих столкновений с противниками — это достаточно эффективный способ постепенно увеличивать сложность, поддерживая вовлеченность игроков. В этом руководстве я поделюсь с вами своим опытом реализации WaveManager'а в Unity, включая создание скриптов, генерацию врагов и интеграцию пользовательского интерфейса.
https://miro.medium.com/v2/resize:fit:700/0*Q88V60jtg3-UngPq
3 волны врагов
Настройка WaveManager’а
Создание GameObject’а WaveManager
К настоящему моменту я создал три модели поведения противника в дополнение к базовому классу. Теперь моя задача заключается в том, чтобы реализовать последовательность вражеских волн, причем каждая последующая волна должна содержать большее количество врагов. Я начну с создания GameObject»а WaveManager. Чтобы создать WaveManager, выполните следующие шаги:
кликните правой кнопкой мыши в вашем Hierarchy View, затем
создайте новый пустой GameObject и переименуйте его в Wave_Manager.


Далее мне нужно создать скрипт WaveManager. Этот скрипт должен будет взаимодействовать с четырьмя другими скриптами: Asteroid, SpawnManager, Enemy и UIManager. Для этого:
Кликните правой кнопкой мыши по папке scripts в Project View, затем
Выберите Create > C# (Script) и назовите его WaveManager.
WaveManager будет действовать как триггер, взаимодействуя со SpawnManager»ом.


Теперь мне нужно открыть скрипт WaveManager и добавить туда ссылку на SpawnManager
, которую я сделаю private
и назову _spawnManager
(чтобы было сразу понятно, на что она ссылается).
WaveManager будет вызывать методы SpawnManager»а для инстанцирования GameObject»ов противников. Таким образом, за фактическое появление врагов с началом каждой новой волны будет отвечать SpawnManager.

Теперь в верхней части класса я объявлю и определю переменные, связанные с логикой WaveManagers.
Мне нужна переменная для хранения количества завершенных волн, поэтому я создам
private int _currentWave
и установлю ее значение равным1
.Мне также нужна переменная для хранения количества врагов, которые будут созданы для текущей волны (
_currentWave
). Опять же, это будетprivate int _enemiesToSpawn
.Мне также необходимо отслеживать количество уничтоженных врагов в текущей волне. Снова
private int _enemiesDestroyed
, значение по умолчанию которого будет0
.
Обычно в любой подобной игре есть определенное количество волн, которые игрок должен преодолеть, чтобы наконец сразиться с боссом. Для этого мне потребуется переменная, которая будет хранить максимальное количество волн. Я объявлю ее как private const int
, назову _maxWaves
и установлю для нее значение по умолчанию 3
.
Ключевое слово const - C# reference
Константы - C#

WaveManager также должен будет взаимодействовать с моим пользовательским интерфейсом (UIManager»ом). Каждый раз, когда инстанцируется новая волна врагов, на экране должен появляться текст, сообщающий игроку, какая именно волна приближается. И снова в верхней части класса я создам private
ссылку на UIManager
и назову ее соответствующим образом — _uiManager
.
И чтобы я мог делать это через UIManager, WaveManager должен иметь ссылку на текстовый компонент, чтобы он мог обновлять пользовательский интерфейс. Поэтому я добавлю private
переменную типа Text
и назову ее _waveText
.

Теперь мне нужно позаботиться о том, чтобы WaveManager мог использовать UIManager для отображения информации о волнах (с самого начала игры). Для этого в методе Start()
я собираюсь установить значение ссылки _uiManager
, найдя для него соответствующий UIManager
. Я использую метод GameObject.Find()
, который идентифицирует GameObject в Hierarchy View по его имени (в виде string). Скрипт UIManager уже прикреплен к GameObject»у с именем «Canvas»
, поэтому я введу его имя точно так, как оно указано, в кавычках. Затем я использую метод GetComponent<UIManager>()
. После этого я выполню проверку на null, чтобы зарегистрировать ошибку, если UIManager не будет найден.

Теперь я приступлю к созданию метода, который будет отвечать за запуск волны. Я назову его StartWave()
и сделаю его public void
. В скобках я укажу параметр, который будет идентифицировать номер волны — int wave
. Первым шагом я проверю, не превышает ли текущее количество волн ранее заданное максимальное значение, равное 3
.

Если оно меньше или равно 3
, UIManager должен будет отобразить waveText
. Сначала я проверю, не отключен ли UIManager
, а затем я вызову uiManager для отображения текущего номера волны на сцене.

Затем мне нужно будет взаимодействовать со SpawnManager, чтобы сообщить ему количество _enemiesToSpawn
. Я собираюсь использовать метод Random.Range()
, который будет зависеть от номера волны. Random.Range()
принимает два параметра: минимум и максимум. Сначала переменная wave
равна 1
. При запуске второй волны wave
будет равна 2
, а при запуске третьей волны wave
будет равна 3
. Итак, в качестве параметра минимума я введу 3 + (wave *2)
. С каждой последующей волной переменная wave
будет увеличиваться, и в результате количество минимальных врагов также будет расти.
Для первой волны: 3 + (1 * 2) = 5.
Для второй волны: 3 + (2 * 2) = 7.
Для третьей волны: 3 + (3 * 2) = 9.
Максимальное значение, которое я буду использовать для Random.Range(), будет составлять 5 + (wave * 3)
.
Для первой волны: 5 + (1 * 3) = 8.
Для второй волны: 5 + (2 * 3) = 11.
Для третьей волны: 5 + (3 * 3) = 14.
С каждой последующей волной переменная wave будет увеличиваться, и в результате максимальное количество врагов также будет увеличиваться.

Затем мне нужно запустить SpawnManager, поэтому я использую определенную ранее ссылку spawnManager
и вызову метод StartSpawning()
. Для того, чтобы метод StartSpawning()
знал, сколько врагов нужно создать и какая сейчас будет волна, мне нужно передать ему две переменные: enemiesToSpawn
и wave
.

После моего оператора if
я добавлю оператор else
, который идентифицирует, что все три волны завершены. Я введу Debug.Log
с сообщением («All waves destroyed!»)
.

Мне также необходимо вести учет количества уничтоженных врагов, чтобы проверить, все ли враги в текущей волне были уничтожены, и переходить к следующей волне или завершать игру, если все 3 волны завершены. Для этого я создам специальный метод с модификатором доступа public void
и назову его EnemyDestroyed()
.
Каждый раз, когда будет уничтожен враг, переменная enemiesDestroyed
будет увеличиваться на единицу, что позволит отслеживать общее количество уничтоженных врагов в текущей волне. Я введу переменную enemiesDestroyed
и буду использовать оператор ++ для ее увеличения.

Далее мне нужен способ проверить, превышает ли количество уничтоженных врагов количество тех, которые были созданы для текущей волны. Для этого я использую оператор if
, который будет проверять (_enemiesDestroyed >= _enemiesToSpawn)
.

Следующая волна может быть запущена, когда это условие становится истинным. Чтобы запустить следующую волну, а также сбросить количество _enemiesDestroyed
, я выполню следующие шаги:
Увеличу переменную
_currentWave
на единицу с помощью оператора++
для перехода к следующей волне.Установлю значение переменной
_enemiesDestroyed
равным0
.

Как только будет определено, что количество уничтоженных врагов достигло количества созданных врагов, мне нужно будет проверить другое условие. Мне нужно знать, является ли currentWave
меньше или равно maxWaves
, что в нашем случае равно 3
. Только тогда метод EnemyDestroyed(
) может вызвать метод StartWave()
и запустить новую волну. Итак, я добавлю StartWave(_currentWave);
. Я также добавлю Debug.Log
, который выведет в консоль сообщение, когда currentWave
будет равен maxWaves
.

Последнее, что нужно сделать в WaveManager, — это настроить корутину для отображения текста. Я начну с создания IEnumerator
и назову его DisplayWaveText()
. Ему необходимо определить номер волны, поэтому я передам параметр int wave
.

Внизу, в фигурных скобках, я собираюсь вызвать
private
переменную_waveText
, перейдя к ееGameObject.SetActive()
.Я передаю
true
в методSetActive()
, чтобы текст отобразился, когдаDisplayWaveText()
будет вызванStartCoroutine()
.Я хочу подождать 2 секунды, а затем удалить текст с экрана. Я использую yield return new WaitForSeconds с параметром
2f
.Затем я снова вызываю переменную
_waveText
, перехожу кGameObject.SetActive()
, чтобы теперь передать туда значениеfalse
.

В метод StartWave()
я добавлю метод StartCoroutine()
. В качестве параметра _waveText
я передам в метод DisplayWaveText()
переменную wave
.

Взаимодействие со скриптом Enemy: создание и уничтожение врага
Скрипт Enemy запускает метод EnemyDestroyed()
, чтобы сообщить WaveManager»у об уничтожении врага. Внутри самого скрипта Enemy уже есть метод TriggerEnemyDeath()
, который я использую для коммуникации со скриптом WaveManager. Каждый враг отвечает за информирование WaveManager»а о том, что он был уничтожен, чтобы WaveManager мог отслеживать прогресс и запускать новые волны.
Первым шагом мне необходимо получить компонент WaveManager. Для этого я определяю переменную под именем waveManager
типа WaveManager
, значение которой установлю с помощью методов GameObject.Find()
и GetComponent<>()
. Я буду использовать точное имя, которое используется в Hierarchy View («Wave_Manager»
), чтобы найти с помощью Find
нужный GameObject
, а затем добавлю имя скрипта в вызове GetComponent
.

Затем я проверяю, не является ли компонент waveManager
null
. Если он не null
, я вызываю его метод EnemyDestroyed()
. Это позволяет уведомить WaveManager о том, что был уничтожен враг. Я также включаю оператор else
, где будет вызываться Debug.LogError()
с сообщением «WaveManager is null or not found!»
.

Увеличение скорости врагов с каждой волной
Чтобы немного повысить сложноть, я решил, что скорость противника будет увеличиваться с каждой новой волной. Для этого я добавляю protected float _increaseWaveSpeed
в верхней части класса.

Я хочу, чтобы increaseWaveSpeed
имел такое же значение, как и speed
, поэтому в методе Start()
я объявлю и определю его соответствующим образом.

Затем я объявляю пользовательский метод public void IntializeForWave()
, который принимает в качестве параметра int wave
, представляющий номер текущей волны. Я хочу на 10% увеличить скорость (_speed
) противника с каждой волной. Для этого я умножаю номер волны на 0.1f
и добавляю полученное произведение к 1
. Это значение затем умножается на значение speed
(2
) и будет равно новому значению increaseWaveSpeed
.

Чтобы это изменение вступило в силу, я возвращаюсь к скрипту SpawnManager и, в методе SpawnEnemyRoutine()
, сразу после оператора switch
, устанавливаю для компонента Enemy значение, полученное из newEnemy
(скрипт Enemy). Как обычно, я использую условный оператор, чтобы проверить, не является ли компонент Enemy null
, затем вызываю метод скрипта Enemy IntializeForWave()
, передавая ему переменную wave
для идентификации текущей волны.

Взаимодействие со скриптом UIManager: интеграция пользовательского интерфейса для отображения информации о волнах
Для отображения номера текущей волны в UIManager используется метод StartWave()
. Однако прежде чем начать работу с этим методом, необходимо открыть скрипт UIManager и в верхней части класса добавить атрибут [SerializeField]
, а под ним private
переменную _waveText
типа Text
.

Вернувшись в редактор Unity, найдите GameObject Canvas
в Hierarchy View, кликните по нему правой кнопкой мыши и в появившемся диалоговом окне выберите UI > Text. Переименуйте созданный GameObject в Wave_Text. Таким образом мы создадим новый GameObject для отображения переменной _waveText
.

Я настрою текстовый элемент в пользовательском интерфейсе и введу текст «Wave Starting». Я хочу расположить текст по центру экрана, поэтому привязываю (Anchor) объект Text в этом месте.

Затем я хочу захватить GameObject Wave_Text
в Hierarchy View и перетащить его в поля для инспектора UIManager и инспектора WaveManager, чтобы они могли получить к нему доступ.

Пока у меня открыт инспектор WaveManager, я также перетащу GameObject SpawnManager в его ячейку для ссылок.

Разобравшись с этим, я вернусь к скрипту UIManager.
Я создам пользовательский метод, который будет вызываться WaveManager»ом для отображения текущего номера волны на сцене. Этот метод будет называться DisplayWave()
. Он будет принимать в качестве параметра целое число, которое идентифицирует номер волны (т. е. int waveNumber
). Это значение будет использоваться в сочетании со строкой для отображения номера волны.

Чтобы установить значение свойства text
переменной _waveText
, я преобразую текст в строку, применяя строковую интерполяцию. Строковая интерполяция позволяет мне встраивать переменные непосредственно в строку, что значительно упрощает создание динамических строк. Согласно Оксфордскому словарю, интерполяция означает:
«включение промежуточного значения или члена в ряд путем оценки или вычисления его на основе окружающих известных значений».
Здесь я вставлю число в строку, используя знак $
перед строкой, чтобы указать, что это строковая интерполяция. Переменная waveNumber
будет внедрена в строку, а выражение {waveNumber}
будет вычислено и заменено фактическим значением переменной waveNumber
во время выполнения.
Используя метод SetActive()
GameObject»а текстового компонента, я активирую его, установив значение true
. Через некоторое время мне нужно будет убрать текст, и здесь на помощь придет корутина, которая мне в этом поможет.

Ниже моего метода отображения номера волны я запущу корутину с IEnumerator
под названием HideWaveTextAfterDelay()
. Этот метод задержит работу на 2 секунды, а затем установит для метода DisplayWave()
значение false
с помощью StartCoroutine()
. Я начну отсчет времени задержки с помощью yield return new WaitForSeconds(2f) (2f
означает 2 секунды). После этого утверждения мне нужно снова установить через метод SetActive()
GameObject»а _waveText
значение false
.

Чтобы завершить процесс и вернуться к методу DisplayWave()
, я вызову StartCoroutine(HideWaveTextAfterDelay())
.

Взаимодействие со SpawnManager
Количество создаваемых врагов будет передано в SpawnManager, так как оно было передано в качестве параметра вместе с переменной wave
при вызове метода StartSpawning()
из WaveManager»а в рамках метода StartWave()
.
Метод StartSpawning()
отвечает за запуск процесса создания врагов с помощью StartCoroutine()
при начале новой волны. WaveManager сообщает ему, сколько врагов нужно создать, int enemyCount
, а также на каком номере волны он находится в данный момент, int wave
.
Внутри StartCoroutine()
, SpawnEnemyRoutine()
теперь будет принимать две переменные: enemyCount
и wave
. enemyCount
— это общее количество врагов, которые должны появиться в текущей волне, и основано на расчетах Random.Range()
и минимальное и максимальное количество врагов в WaveManager.

Теперь в корутину SpawnEnemyRoutine()
в качестве параметров будут добавлены переменные int enemyCount
и int wave
.

Теперь я создам цикл for
, который будет непрерывно добавлять врагов для инстанцирования, пока не будет достигнуто указанное количество врагов.

Взаимодействие с Asteroid’ом: подключение к скрипту Asteroid
Asteroid выполняет важную роль в игре — отвечает за запуск первой волны игры. Как только он будет уничтожен, ему необходимо вызвать WaveManager вместо SpawnManager, чтобы запустить первую волну.
Когда игра начнется, мне нужно получить доступ к waveManager
. Я создаю [SerializeField]
в верхней части класса Asteroid для private
переменной waveManager
типа WaveManager
.

Затем, вернувшись в редактор Unity, я перетаскиваю GameObject Wave_Manager из Hierarchy View в инспектор Asteroid.

Наконец, возвращаясь к скрипту Asteroid, в методе Start()
я использую метод GameObject.findObject(«Wave_Manager»)
, чтобы найти и получить доступ к скрипту WaveManager с помощью GetComponent<WaveManager>()
, как мы уже делали ранее.
Чуть ниже я добавляю стандартную проверку компонента на null
и Debug.LogError
, если WaveManager все‑таки имеет значение null
.

Как мне запустить первую волну? В скрипте Asteroid уже есть метод private void OnTriggerEnter2D()
. Уже существует условный оператор, используемый для проверки, столкнется ли «Laser»
с Asteroid»ом. Если это так, то выполняются специальные методы. Asteroid запустит (Instantiate
) анимацию взрыва и уничтожит (Destroy
) GameObject лазера, а затем и себя. После того, как лазер будет уничтожен, я хочу, чтобы началась первая волна. Здесь я вызову метод StartWave()
параметра waveManager
, куда передаю число 1
, которое идентифицирует currentWave
.

Внедрение хорошо продуманной системы управления волнами значительно повысило уровень реиграбельности моей игры и помогло настроить кривую сложности. Хотя мне еще предстоит устранить некоторые ошибки, я разработал с вами основу гибкого WaveManager»а, который управляет появлением врагов, отслеживает прогресс волн и интегрируется с элементами пользовательского интерфейса. Эта система управления волнами может быть расширена за счет более сложных схем, разнообразных типов врагов и дополнительных игровых механик, что позволит создавать все более увлекательные и сложные игры.
https://miro.medium.com/v2/resize:fit:720/format:webp/0*ceKM2hbbk6zSzS2L
3 волны врагов
Пора пересмотреть архитектуру проекта?
Если система растёт, а вносить изменения становится всё труднее — вы близки к точке невозврата.
Приходите на два бесплатных вебинара:
А ещё — пройдите вступительное тестирование по разработке на Unity и узнайте, с какого уровня начать обучение.
Kwisatz
Убивать надо тех кто придумал волны. Раньше можно было весело отстреливаться пока тебя захлестывает рой противников. А сейчас во всех играх ты уныло круги нарезаешь вокруг точки в ожидании респа очередной волны. Даже во POE2, где ты можешь убивать по 4 экрана за выстрел, все равно полно механик "побегай кругами, подожди"
Rive
Вероятно, это была реакция на то, что игроки доставали из этого игрового автомата с краном награду в обход собственно геймплея, аккуратно обходя всех монстров по стеночке или не мудрствуя лукаво пробегая сквозь них.
А следовательно там, где была задача сделать игрокам dps-чек для фильтрации по силе экипировки, волны могли выглядеть для геймдизайнеров более привлекательной реализацией доставки мишеней, чем прохождение через лабиринт с ними.
Kwisatz
Значит расчитывайте так чтобы самый сильный игрок потел от них. Дайте уже нормальных волн, чтоб я ссался перезарядку нажать или вынужден был заклинания вдумчиво прожимать
Rive
В Diablo III волны на аренах продолжались до тех пор, пока игроки не захлёбывались в волнах из-за усиливающихся врагов.
Но это очень специфичный dps-чекер, игроки могут огорчиться что всегда проигрывают в конце.
Kwisatz
В diablo 3 было много вкусного и приятного, очень динамичная была игра, в этом плане D4 ей в подметки не годится