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