Изображение создано с помощью DALL.E3
Изображение создано с помощью DALL.E3

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

Далее мне нужно создать скрипт WaveManager. Этот скрипт должен будет взаимодействовать с четырьмя другими скриптами: Asteroid, SpawnManager, Enemy и UIManager. Для этого:

  • Кликните правой кнопкой мыши по папке scripts в Project View, затем

  • Выберите Create > C# (Script) и назовите его WaveManager.

WaveManager будет действовать как триггер, взаимодействуя со SpawnManager»ом.

Create > C# Script
Create > C# Script
Переименуйте скрипт в WaveManager
Переименуйте скрипт в WaveManager

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

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

Ссылка на GameObject SpawnManager»а
Ссылка на GameObject SpawnManager»а

Теперь в верхней части класса я объявлю и определю переменные, связанные с логикой WaveManagers.

  • Мне нужна переменная для хранения количества завершенных волн, поэтому я создам private int _currentWave и установлю ее значение равным 1.

  • Мне также нужна переменная для хранения количества врагов, которые будут созданы для текущей волны (_currentWave). Опять же, это будет private int _enemiesToSpawn.

  • Мне также необходимо отслеживать количество уничтоженных врагов в текущей волне. Снова private int _enemiesDestroyed, значение по умолчанию которого будет 0.

Обычно в любой подобной игре есть определенное количество волн, которые игрок должен преодолеть, чтобы наконец сразиться с боссом. Для этого мне потребуется переменная, которая будет хранить максимальное количество волн. Я объявлю ее как private const int, назову _maxWaves и установлю для нее значение по умолчанию 3.

Ключевое слово const - C# reference

learn.microsoft.com

Константы - C#

learn.microsoft.com

Атрибуты, необходимые для логики WaveManager»а
Атрибуты, необходимые для логики WaveManager»а

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

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

Ссылки на GameObject»ы Text и UIManager
Ссылки на GameObject»ы Text и UIManager

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

Получение UIManager»а с проверкой, что он не null.
Получение UIManager»а с проверкой, что он не null.

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

Проверка, является ли текущее число волн меньше или равно максимальному количеству волн
Проверка, является ли текущее число волн меньше или равно максимальному количеству волн

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

Использование метода DisplayWave() для отображения номера текущей волны
Использование метода DisplayWave() для отображения номера текущей волны

Затем мне нужно будет взаимодействовать со 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.

Передача SpawnManager информации о том, сколько врагов нужно создать и какая сейчас волна
Передача SpawnManager информации о том, сколько врагов нужно создать и какая сейчас волна

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

Debug.Log, уведомляющий о завершении всех 3 волн
Debug.Log, уведомляющий о завершении всех 3 волн

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

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

Метод EnemyDestroyed отслеживает количество уничтоженных врагов.
Метод EnemyDestroyed отслеживает количество уничтоженных врагов.

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

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

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

  • Увеличу переменную _currentWave на единицу с помощью оператора ++ для перехода к следующей волне.

  • Установлю значение переменной _enemiesDestroyed равным 0.

Увеличиваю номер текущей волны на 1 и сбрасываю количество уничтоженных врагов
Увеличиваю номер текущей волны на 1 и сбрасываю количество уничтоженных врагов

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

Отображение текста в течение 2 секунд, после чего он исчезнет
Отображение текста в течение 2 секунд, после чего он исчезнет

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

Добавление StartCoroutine для отображения текста с номером текущей волны
Добавление StartCoroutine для отображения текста с номером текущей волны

Взаимодействие со скриптом Enemy: создание и уничтожение врага

Скрипт Enemy запускает метод EnemyDestroyed(), чтобы сообщить WaveManager»у об уничтожении врага. Внутри самого скрипта Enemy уже есть метод TriggerEnemyDeath(), который я использую для коммуникации со скриптом WaveManager. Каждый враг отвечает за информирование WaveManager»а о том, что он был уничтожен, чтобы WaveManager мог отслеживать прогресс и запускать новые волны.

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

Скрипт Enemy извлекает скрипт WaveManager
Скрипт Enemy извлекает скрипт WaveManager

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

Увеличение скорости врагов с каждой волной

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

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

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

Инициализация increasedWaveSpeed равным speed
Инициализация increasedWaveSpeed равным speed

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

Увеличение скорости каждой волны на 10%
Увеличение скорости каждой волны на 10%

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

Находим скрипт Enemy и инициируем увеличение скорости для текущей волны.
Находим скрипт Enemy и инициируем увеличение скорости для текущей волны.

Взаимодействие со скриптом UIManager: интеграция пользовательского интерфейса для отображения информации о волнах

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

Добавление ссылки на GameObject текстового элемента пользовательского интерфейса
Добавление ссылки на GameObject текстового элемента пользовательского интерфейса

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

Создание GameObject»а Text
Создание GameObject»а Text

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

Настройка GameObject»а Wave_Text
Настройка GameObject»а Wave_Text

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

Перетащите GameObject в ячейки для ссылок на скрипты в UIManager»е
Перетащите GameObject в ячейки для ссылок на скрипты в UIManager»е

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

Перетащите GameObject в ячейки для ссылок на скрипты в SpawnManager
Перетащите GameObject в ячейки для ссылок на скрипты в SpawnManager

Разобравшись с этим, я вернусь к скрипту UIManager.

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

Заготовка метода отображения Wave_Text
Заготовка метода отображения Wave_Text

Чтобы установить значение свойства text переменной _waveText, я преобразую текст в строку, применяя строковую интерполяцию. Строковая интерполяция позволяет мне встраивать переменные непосредственно в строку, что значительно упрощает создание динамических строк. Согласно Оксфордскому словарю, интерполяция означает:

«включение промежуточного значения или члена в ряд путем оценки или вычисления его на основе окружающих известных значений».

Здесь я вставлю число в строку, используя знак $ перед строкой, чтобы указать, что это строковая интерполяция. Переменная waveNumber будет внедрена в строку, а выражение {waveNumber} будет вычислено и заменено фактическим значением переменной waveNumber во время выполнения.

Используя метод SetActive() GameObject»а текстового компонента, я активирую его, установив значение true. Через некоторое время мне нужно будет убрать текст, и здесь на помощь придет корутина, которая мне в этом поможет.

Интерполяция строк и активация GameObject»а текстового компонента
Интерполяция строк и активация GameObject»а текстового компонента

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

Корутина для отключения текстового компонента через 2 секунды
Корутина для отключения текстового компонента через 2 секунды

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

Запуск корутины для деактивации текста в методе DisplayWave
Запуск корутины для деактивации текста в методе DisplayWave

Взаимодействие со SpawnManager

Количество создаваемых врагов будет передано в SpawnManager, так как оно было передано в качестве параметра вместе с переменной wave при вызове метода StartSpawning() из WaveManager»а в рамках метода StartWave().

Метод StartSpawning() отвечает за запуск процесса создания врагов с помощью StartCoroutine() при начале новой волны. WaveManager сообщает ему, сколько врагов нужно создать, int enemyCount, а также на каком номере волны он находится в данный момент, int wave.

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

SpawnEnemyRoutine теперь создает врагов на основе двух параметров.
SpawnEnemyRoutine теперь создает врагов на основе двух параметров.

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

Передача двух параметров для управления количеством врагов, порожденных для определенной волны
Передача двух параметров для управления количеством врагов, порожденных для определенной волны

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

Увеличивайте количество врагов до тех пор, пока не будут удовлетворены требования WaveManager
Увеличивайте количество врагов до тех пор, пока не будут удовлетворены требования WaveManager

Взаимодействие с Asteroid’ом: подключение к скрипту Asteroid

Asteroid выполняет важную роль в игре — отвечает за запуск первой волны игры. Как только он будет уничтожен, ему необходимо вызвать WaveManager вместо SpawnManager, чтобы запустить первую волну.

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

Ссылка на GameObject Wave Manager
Ссылка на GameObject Wave Manager

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

Перетащите GameObject Wave_Manager в ячейку для ссылок на скрипты Asteroid
Перетащите GameObject Wave_Manager в ячейку для ссылок на скрипты Asteroid

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

Чуть ниже я добавляю стандартную проверку компонента на null и Debug.LogError, если WaveManager все‑таки имеет значение null.

Извлечение и проверка на null скрипта WaveManager
Извлечение и проверка на null скрипта WaveManager

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

Вызов метода StartWave() WaveManager»а
Вызов метода StartWave() WaveManager»а

Внедрение хорошо продуманной системы управления волнами значительно повысило уровень реиграбельности моей игры и помогло настроить кривую сложности. Хотя мне еще предстоит устранить некоторые ошибки, я разработал с вами основу гибкого WaveManager»а, который управляет появлением врагов, отслеживает прогресс волн и интегрируется с элементами пользовательского интерфейса. Эта система управления волнами может быть расширена за счет более сложных схем, разнообразных типов врагов и дополнительных игровых механик, что позволит создавать все более увлекательные и сложные игры.

https://miro.medium.com/v2/resize:fit:720/format:webp/0*ceKM2hbbk6zSzS2L

3 волны врагов


Пора пересмотреть архитектуру проекта?
Если система растёт, а вносить изменения становится всё труднее — вы близки к точке невозврата.

Приходите на два бесплатных вебинара:

А ещё — пройдите вступительное тестирование по разработке на Unity и узнайте, с какого уровня начать обучение.

Комментарии (5)


  1. Kwisatz
    19.06.2025 14:28

    Убивать надо тех кто придумал волны. Раньше можно было весело отстреливаться пока тебя захлестывает рой противников. А сейчас во всех играх ты уныло круги нарезаешь вокруг точки в ожидании респа очередной волны. Даже во POE2, где ты можешь убивать по 4 экрана за выстрел, все равно полно механик "побегай кругами, подожди"


    1. Rive
      19.06.2025 14:28

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

      А следовательно там, где была задача сделать игрокам dps-чек для фильтрации по силе экипировки, волны могли выглядеть для геймдизайнеров более привлекательной реализацией доставки мишеней, чем прохождение через лабиринт с ними.


      1. Kwisatz
        19.06.2025 14:28

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


        1. Rive
          19.06.2025 14:28

          В Diablo III волны на аренах продолжались до тех пор, пока игроки не захлёбывались в волнах из-за усиливающихся врагов.

          Но это очень специфичный dps-чекер, игроки могут огорчиться что всегда проигрывают в конце.


          1. Kwisatz
            19.06.2025 14:28

            В diablo 3 было много вкусного и приятного, очень динамичная была игра, в этом плане D4 ей в подметки не годится