Создание игры Tower Defense в Unity: сценарии и волны врагов +20


[Первая, вторая, третья и четвёртая части туториала]

  • Поддержка врагов малого, среднего и крупного размеров.
  • Создание игровых сценариев со множественными волнами врагов.
  • Разделение конфигурации ассетов и геймплейного состояния.
  • Запуск, пауза, победа, поражение и ускорение игры.
  • Создание бесконечно повторяющихся сценариев.

Это пятая часть серии туториалов, посвящённых созданию простой игры в жанре tower defense. В ней мы научимся создавать геймплейные сценарии, порождающие волны разнообразных врагов.

Туториал создавался в Unity 2018.4.6f1.


Становится довольно уютно.

Больше врагов


Не очень интересно каждый раз создавать одинаковый синий куб. Первым шагом к поддержке более интересных геймплейных сценариев станет поддержка нескольких типов врагов.

Конфигурации врагов


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

public enum EnemyType {
	Small, Medium, Large
}

Изменим EnemyFactory так, чтобы она поддерживала вместо одного все эти три типа врагов. Для всех трёх врагов нужны одинаковые поля конфигурации, поэтому добавим вложенный класс EnemyConfig, содержащий их все, а затем добавим три поля конфигурации этого типа к фабрике. Так как этот класс применяется только для конфигурации и мы нигде больше не будем его использовать, то можно просто сделать его поля публичными, чтобы фабрика могла получать к ним доступ. Сам EnemyConfig не обязан быть публичным.

public class EnemyFactory : GameObjectFactory {

	[System.Serializable]
	class EnemyConfig {

		public Enemy prefab = default;

		[FloatRangeSlider(0.5f, 2f)]
		public FloatRange scale = new FloatRange(1f);

		[FloatRangeSlider(0.2f, 5f)]
		public FloatRange speed = new FloatRange(1f);

		[FloatRangeSlider(-0.4f, 0.4f)]
		public FloatRange pathOffset = new FloatRange(0f);
	}
	
	[SerializeField]
	EnemyConfig small = default, medium = default, large = default;
	
	…
}

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

		[FloatRangeSlider(10f, 1000f)]
		public FloatRange health = new FloatRange(100f);

Добавим к Get параметр типа, чтобы было можно получать конкретный тип врага, и типом по умолчанию будет средний. Воспользуемся типом для получения правильной конфигурации, для чего пригодится отдельный метод, а затем создадим и инициализируем врага как и раньше, только с добавленным аргументом здоровья.

	EnemyConfig GetConfig (EnemyType type) {
		switch (type) {
			case EnemyType.Small: return small;
			case EnemyType.Medium: return medium;
			case EnemyType.Large: return large;
		}
		Debug.Assert(false, "Unsupported enemy type!");
		return null;
	}

	public Enemy Get (EnemyType type = EnemyType.Medium) {
		EnemyConfig config = GetConfig(type);
		Enemy instance = CreateGameObjectInstance(config.prefab);
		instance.OriginFactory = this;
		instance.Initialize(
			config.scale.RandomValueInRange,
			config.speed.RandomValueInRange,
			config.pathOffset.RandomValueInRange,
			config.health.RandomValueInRange
		);
		return instance;
	}

Добавим в Enemy.Initialize обязательный параметр health и используем его для задания здоровья вместо того, чтобы определять его по размеру врага.

	public void Initialize (
		float scale, float speed, float pathOffset, float health
	) {
		…
		Health = health;
	}

Создаём дизайн разных врагов


Вы можете сами выбирать, каким будет дизайн трёх врагов, но в туториале я буду стремиться к максимальной простоте. Я продублировал исходный префаб врага и использовал его для всех трёх размеров, меняя только материал: жёлтый для мелкого, синий для среднего и красный для крупного. Я не менял масштаб префаба куба, а использовал для задания размеров конфигурацию масштаба фабрики. Также в зависимости от размера я увеличил им здоровье и уменьшил скорость.


Фабрика для врагов-кубов трёх размеров.

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

	void SpawnEnemy () {
		GameTile spawnPoint =
			board.GetSpawnPoint(Random.Range(0, board.SpawnPointCount));
		Enemy enemy = enemyFactory.Get((EnemyType)(Random.Range(0, 3)));
		enemy.SpawnOn(spawnPoint);
		enemies.Add(enemy);
	}


Враги разных типов.

Несколько фабрик


Теперь фабрика врагов задаёт множество из трёх врагов. Имеющаяся фабрика создаёт кубы трёх размеров, но ничто не мешает нам сделать ещё одну фабрику, создающую что-то иное, например сферы трёх размеров. Мы можем менять создаваемых врагов, назначая в игре другую фабрику, переключаясь таким образом на другую тему.


Сферические враги.

Волны врагов


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

Последовательности создания


Одна волна врагов состоит из группы создаваемых один за другим врагов до завершения волны. Волна может содержать разные типы врагов, а задержка между их созданием может варьироваться. Чтобы не усложнять реализацию, мы начнём с простой последовательности порождения, создающей одинаковый тип врагов с постоянной частотой. Тогда волна будет просто списком таких последовательностей.

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

using UnityEngine;

[System.Serializable]
public class EnemySpawnSequence {

	[SerializeField]
	EnemyFactory factory = default;

	[SerializeField]
	EnemyType type = EnemyType.Medium;

	[SerializeField, Range(1, 100)]
	int amount = 1;

	[SerializeField, Range(0.1f, 10f)]
	float cooldown = 1f;
}

Волны


Волна — это простой массив последовательностей создания врагов. Создадим для неё тип ассета EnemyWave, который начинается с одной стандартной последовательности.

using UnityEngine;

[CreateAssetMenu]
public class EnemyWave : ScriptableObject {

	[SerializeField]
	EnemySpawnSequence[] spawnSequences = {
		new EnemySpawnSequence()
	};
}

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


Волна увеличивающихся в размерах кубов.

Можно ли добавить между последовательностями задержку?
Можно реализовать её косвенно. Например, вставить четырёхсекундную задержку между мелкими и средними кубами, уменьшить количество мелких кубов на один и вставить последовательность из одного мелкого куба, имеющего паузу в четыре секунды.


Четырёхсекундная задержка между мелкими и средними кубами.

Сценарии


Геймплейный сценарий создаётся из последовательности волн. Создадим для этого тип ассета GameScenario с одним массивом волн, а затем используем его для изготовления сценария.

using UnityEngine;

[CreateAssetMenu]
public class GameScenario : ScriptableObject {

	[SerializeField]
	EnemyWave[] waves = {};
}

Например, я создал сценарий с двумя волнами мелких-средних-крупных врагов (МСК), сначала с кубами, потом со сферами.


Сценарий с двумя волнами МСК.

Движение по последовательности


Для создания сценариев используются типы ассетов, но поскольку это ассеты, они должны содержать данные, которые во время игры не меняются. Однако для продвижения по сценарию нам каким-то образом нужно отслеживать их состояние. Один из способов заключается в дублировании используемого в игре ассета, чтобы дубликат отслеживал его состояние. Но нам не требуется дублировать ассет целиком, достаточно состояния и ссылки на ассет. Поэтому давайте создадим отдельный класс State, сначала для EnemySpawnSequence. Так как он применяется только к последовательности, сделаем его вложенным. Он действителен только когда имеет ссылку на последовательность, поэтому дадим ему метод-конструктор с параметром-последовательностью.


Вложенный тип состояния, ссылающийся на свою последовательность.

public class EnemySpawnSequence {

	…

	public class State {

		EnemySpawnSequence sequence;

		public State (EnemySpawnSequence sequence) {
			this.sequence = sequence;
		}
	}
}

Когда мы хотим начать продвижение по последовательности, нам требуется для этого новый экземпляр состояния. Добавим последовательности метод Begin, конструирующий и возвращающий состояние. Благодаря этому все, кто будет вызывать Begin, будут нести ответственность за соответствие состоянию, а сама последовательность будет оставаться не имеющей состояния. Возможно будет даже параллельно продвигаться несколько раз по одной и той же последовательности.

public class EnemySpawnSequence {

	…
	
	public State Begin () => new State(this);

	public class State { … }
}

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

	[System.Serializable]
	public class State { … }

Недостаток такого подхода в том, что каждый раз при запуске последовательности нам требуется создавать новый объект состояния. Мы можем избежать выделения памяти, сделав его вместо класса структурой. Это нормально, пока состояние остаётся небольшим. Просто учитывайте, что состояние — это тип-значение. При его передаче оно копируется, поэтому отслеживайте его в одном месте.

	[System.Serializable]
	public struct State { … }

Состояние последовательности состоит всего из двух аспектов: количества порождаемых врагов и продвижения времени паузы. Добавим метод Progress, увеличивающий величину паузы на дельту времени, а затем сбрасывающий её при достижении сконфигурированного значения, аналогично тому, как происходит с временем порождения в Game.Update. Будем выполнять инкремент счёта врагов каждый раз, когда это происходит. Кроме того, значение паузы должно начинаться с максимального значения, чтобы последовательность создавала врагов без паузы в начале.

		int count;

		float cooldown;

		public State (EnemySpawnSequence sequence) {
			this.sequence = sequence;
			count = 0;
			cooldown = sequence.cooldown;
		}
		
		public void Progress () {
			cooldown += Time.deltaTime;
			while (cooldown >= sequence.cooldown) {
				cooldown -= sequence.cooldown;
				count += 1;
			}
		}


Состояние содержит только необходимые данные.

Можно ли получить доступ к EnemySpawnSequence.cooldown из State?
Да, потому что State задано в той же области видимости. Поэтому вложенные типы знают о приватных членах содержащих их типов.

Продвижение должно продолжаться, пока не будет создано нужное количество врагов и не закончится пауза. В этот момент Progress должен сообщать о завершении, но скорее всего мы немного перепрыгнем через величину. Следовательно, в этот момент мы должны вернуть лишнее время, чтобы использовать его в продвижении по следующей последовательности. Чтобы это сработало, нужно превратить дельту времени в параметр. Также нам нужно обозначить то, что мы пока не закончили, и это можно реализовать возвратом отрицательного значения.

		public float Progress (float deltaTime) {
			cooldown += deltaTime;
			while (cooldown >= sequence.cooldown) {
				cooldown -= sequence.cooldown;
				if (count >= sequence.amount) {
					return cooldown;
				}
				count += 1;
			}
			return -1f;
		}

Создание врагов в любой точке


Чтобы последовательности могли порождать врагов, нам нужно преобразовать Game.SpawnEnemy в другой публичный статический метод.

	public static void SpawnEnemy (EnemyFactory factory, EnemyType type) {
		GameTile spawnPoint = instance.board.GetSpawnPoint(
			Random.Range(0, instance.board.SpawnPointCount)
		);
		Enemy enemy = factory.Get(type);
		enemy.SpawnOn(spawnPoint);
		instance.enemies.Add(enemy);
	}

Так как сам Game больше не будет порождать врагов, мы можем удалить из Update фабрику врагов, скорость создания, процесс продвижения создания и код создания врагов.

	void Update () {
			}

Мы будем вызывать Game.SpawnEnemy в EnemySpawnSequence.State.Progress после увеличения счёта врагов.

		public float Progress (float deltaTime) {
			cooldown += deltaTime;
			while (cooldown >= sequence.cooldown) {
				…
				count += 1;
				Game.SpawnEnemy(sequence.factory, sequence.type);
			}
			return -1f;
		}

Продвижение по волне


Примерим тот же подход к продвижению по последовательности, что и при продвижении по целой волне. Дадим EnemyWave его собственный метод Begin, возвращающий новый экземпляр вложенной структуры State. В данном случае состояние содержит индекс волны и состояние активной последовательности, которое мы инициализируем началом первой последовательности.


Состояние волны, содержащее состояние последовательности.

public class EnemyWave : ScriptableObject {

	[SerializeField]
	EnemySpawnSequence[] spawnSequences = {
		new EnemySpawnSequence()
	};

	public State Begin() => new State(this);

	[System.Serializable]
	public struct State {

		EnemyWave wave;

		int index;

		EnemySpawnSequence.State sequence;

		public State (EnemyWave wave) {
			this.wave = wave;
			index = 0;
			Debug.Assert(wave.spawnSequences.Length > 0, "Empty wave!");
			sequence = wave.spawnSequences[0].Begin();
		}
	}
}

Добавим также EnemyWave.State метод Progress, в котором используется тот же подход, что и раньше, то с небольшими изменениями. Начинаем с продвижения по активной последовательности и заменяем дельту времени результатом этого вызова. Пока остаётся время, перемещаемся к следующей последовательности, если она доступа, и выполняем продвижение по ней. Если последовательностей не осталось, то возвращаем оставшееся время; в противном случае возвращаем отрицательное значение.

		public float Progress (float deltaTime) {
			deltaTime = sequence.Progress(deltaTime);
			while (deltaTime >= 0f) {
				if (++index >= wave.spawnSequences.Length) {
					return deltaTime;
				}
				sequence = wave.spawnSequences[index].Begin();
				deltaTime = sequence.Progress(deltaTime);
			}
			return -1f;
		}

Продвижение по сценарию


Добавим GameScenario такую же обработку. В данном случае состояние содержит индекс волны и состояние активной волны.

public class GameScenario : ScriptableObject {

	[SerializeField]
	EnemyWave[] waves = {};

	public State Begin () => new State(this);

	[System.Serializable]
	public struct State {

		GameScenario scenario;

		int index;

		EnemyWave.State wave;

		public State (GameScenario scenario) {
			this.scenario = scenario;
			index = 0;
			Debug.Assert(scenario.waves.Length > 0, "Empty scenario!");
			wave = scenario.waves[0].Begin();
		}
	}
}

Так как мы находимся на верхнем уровне, метод Progress не требует параметра и можно использовать непосредственно Time.deltaTime. Нам не нужно возвращать оставшееся время, но требуется показывать, завершён ли сценарий. Будем возвращать false после завершения последней волны и true, чтобы показать, что сценарий всё ещё активен.

		public bool Progress () {
			float deltaTime = wave.Progress(Time.deltaTime);
			while (deltaTime >= 0f) {
				if (++index >= scenario.waves.Length) {
					return false;
				}
				wave = scenario.waves[index].Begin();
				deltaTime = wave.Progress(deltaTime);
			}
			return true;
		}

Запуск сценария


Для воспроизведения сценария Game требуется поле конфигурации сценария и отслеживание его состояния. Мы будем просто запускать сценарий в Awake и выполнять продвижение по нему Update до обновления состояния остальной части игры.

	[SerializeField]
	GameScenario scenario = default;

	GameScenario.State activeScenario;
	
	…
	
	void Awake () {
		board.Initialize(boardSize, tileContentFactory);
		board.ShowGrid = true;
		activeScenario = scenario.Begin();
	}
	
	…
	
	void Update () {
		…

		activeScenario.Progress();

		enemies.GameUpdate();
		Physics.SyncTransforms();
		board.GameUpdate();
		nonEnemies.GameUpdate();
	}

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


Две волны, ускоренные в 10 раз.

Начало и завершение игр


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

Начало новой игры


В идеале нам нужна возможность начать новую игру в любой момент времени. Для этого потребуется сбросить текущее состояние всей игры, то есть нам придётся сбрасывать множество объектов. Для начала добавим в GameBehaviorCollection метод Clear, утилизирующий все его поведения.

	public void Clear () {
		for (int i = 0; i < behaviors.Count; i++) {
			behaviors[i].Recycle();
		}
		behaviors.Clear();
	}

Это предполагает, что утилизировать можно все поведения, но пока это не так. Чтобы это заработало, добавим в GameBehavior абстрактный метод Recycle.

	public abstract void Recycle ();

Метод Recycle класса WarEntity должен явным образом переопределять его.

	public override void Recycle () {
		originFactory.Reclaim(this);
	}

У Enemy пока нет метода Recycle, поэтому добавим его. Всё, что он должен делать — заставлять фабрику возвращать его обратно. Затем вызовем Recycle везде, где мы напрямую выполняем доступ к фабрике.

	public override bool GameUpdate () {
		if (Health <= 0f) {
			Recycle();
			return false;
		}

		progress += Time.deltaTime * progressFactor;
		while (progress >= 1f) {
			if (tileTo == null) {
				Recycle();
				return false;
			}
			…
		}
		…
	}

	public override void Recycle () {
		OriginFactory.Reclaim(this);
	}

GameBoard тоже нужно сбросить, поэтому дадим ему метод Clear, опустошающий все тайлы, сбрасывающий все точки создания и обновляющийся контент, а затем задающий стандартные начальную и конечную точки. Затем вместо повторения кода мы можем вызвать Clear в конце Initialize.

	public void Initialize (
		Vector2Int size, GameTileContentFactory contentFactory
	) {
		…
		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…

			}
		}

		Clear();
	}
	
	public void Clear () {
		foreach (GameTile tile in tiles) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
		}
		spawnPoints.Clear();
		updatingContent.Clear();
		ToggleDestination(tiles[tiles.Length / 2]);
		ToggleSpawnPoint(tiles[0]);
	}

Теперь мы можем добавить в Game метод BeginNewGame, сбрасывающий врагов, другие объекты и поле, а затем начинающий новый сценарий.

	void BeginNewGame () {
		enemies.Clear();
		nonEnemies.Clear();
		board.Clear();
		activeScenario = scenario.Begin();
	}

Будем вызывать этот метод в Update в случае нажатии клавиши B до продвижения по сценарию.

	void Update () {
		…

		if (Input.GetKeyDown(KeyCode.B)) {
			BeginNewGame();
		}

		activeScenario.Progress();

		…
	}

Проигрыш


Цель игры — победить всех врагов, прежде чем определённое их число доберётся до конечной точки. Количество врагов, необходимых для срабатывания условия поражения, зависит от исходного здоровья игрока, для которого мы добавим в Game поле конфигурации. Так как мы считаем врагов, то будем использовать integer, а не float.

	[SerializeField, Range(0, 100)]
	int startingPlayerHealth = 10;



Изначально игрок имеет 10 единиц здоровья.

В случае Awake или начала новой игры присваиваем текущему здоровью игрока исходное значение.

	int playerHealth;

	…
	
	void Awake () {
		playerHealth = startingPlayerHealth;
		…
	}
	
	void BeginNewGame () {
		playerHealth = startingPlayerHealth;
		…
	}

Добавим публичный статический метод EnemyReachedDestination, чтобы враги могли сообщать Game, что достигли конечной точки. Когда это происходит, уменьшаем здоровье игрока.

	public static void EnemyReachedDestination () {
		instance.playerHealth -= 1;
	}

Вызовем этот метод в Enemy.GameUpdate в подходящий момент времени.

			if (tileTo == null) {
				Game.EnemyReachedDestination();
				Recycle();
				return false;
			}

Теперь мы можем проверять в Game.Update условие поражения. Если здоровье игрока равно или меньше нуля, то срабатывает условие поражения. Мы просто выведем эту информацию в лог и сразу же начнём новую игру до продвижения по сценарию. Но делать мы это будем только при положительном исходном здоровье. Это позволяет нам использовать в качестве начального здоровья 0, благодаря чему проиграть становится невозможно. Так нам удобно будет тестировать сценарии.

		if (playerHealth <= 0 && startingPlayerHealth > 0) {
			Debug.Log("Defeat!");
			BeginNewGame();
		}

		activeScenario.Progress();

Победа


Альтернативой поражению является победа, которая достигается при завершении сценария, если игрок всё ещё жив. То есть когда результат GameScenario.Progess равен false, выводим в лог сообщение о победе, начинаем новую игру, и сразу же продвигаемся по ней.

		if (playerHealth <= 0) {
			Debug.Log("Defeat!");
			BeginNewGame();
		}

		if (!activeScenario.Progress()) {
			Debug.Log("Victory!");
			BeginNewGame();
			activeScenario.Progress();
		}

Однако при этом победа настанет после завершения последней паузы, даже если на поле всё ещё есть враги. Нам нужно отложить победу на момент, когда все враги пропадут, что можно реализовать проверкой того, пуста ли коллекция врагов. Мы допустим, что у неё есть свойство IsEmpty.

		if (!activeScenario.Progress() && enemies.IsEmpty) {
			Debug.Log("Victory!");
			BeginNewGame();
			activeScenario.Progress();
		}

Добавим нужное свойство в GameBehaviorCollection.

	public bool IsEmpty => behaviors.Count == 0;

Контроль времени


Давайте также реализуем возможность управления временем, это поможет в тестировании и часто является геймплейной функцией. Для начала пусть Game.Update проверяет нажатие на пробел, и использует это событие для включения/отключения паузы в игре. Это можно сделать, переключая значения Time.timeScale между нулём и единицей. Это не изменит игровую логику, но заставит все объекты замереть на месте. Или же можно использовать вместо 0 очень малое значение, например 0.01, чтобы создать чрезвычайно замедленное движение.

	const float pausedTimeScale = 0f;
	
	…
	
	void Update () {
		…
		
		if (Input.GetKeyDown(KeyCode.Space)) {
			Time.timeScale =
				Time.timeScale > pausedTimeScale ? pausedTimeScale : 1f;
		}

		if (Input.GetKeyDown(KeyCode.B)) {
			BeginNewGame();
		}
		…
	}

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

	[SerializeField, Range(1f, 10f)]
	float playSpeed = 1f;


Скорость игры.

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

		if (Input.GetKeyDown(KeyCode.Space)) {
			Time.timeScale =
				Time.timeScale > pausedTimeScale ? pausedTimeScale : playSpeed;
		}
		else if (Time.timeScale > pausedTimeScale) {
			Time.timeScale = playSpeed;
		}

Циклические сценарии


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

Циклическое продвижение по волнам


Добавим в GameScenario ползунок конфигурации для задания количества циклов, по умолчанию присвоим ему значение 1. Минимумом сделаем ноль, при этом сценарий будет повторяться бесконечно. Так мы создадим сценарий выживания, в котором нельзя победить, а смысл заключается в том, чтобы проверить, сколько сможет продержаться игрок.

	[SerializeField, Range(0, 10)]
	int cycles = 1;


Сценарий с двумя циклами.

Теперь GameScenario.State должен отслеживать номер цикла.

		int cycle, index;

		EnemyWave.State wave;

		public State (GameScenario scenario) {
			this.scenario = scenario;
			cycle = 0;
			index = 0;
			wave = scenario.waves[0].Begin();
		}

В Progress мы будем выполнять после завершения инкремент цикла, и возвращать false, только если прошли достаточное количество циклов. В противном случае мы сбрасываем индекс волны на ноль и продолжаем движение.

		public bool Progress () {
			float deltaTime = wave.Progress(Time.deltaTime);
			while (deltaTime >= 0f) {
				if (++index >= scenario.waves.Length) {
					if (++cycle >= scenario.cycles && scenario.cycles > 0) {
						return false;
					}
					index = 0;
				}
				wave = scenario.waves[index].Begin();
				deltaTime = wave.Progress(deltaTime);
			}
			return true;
		}

Ускорение


Если игроку удалось победить цикл один раз, то он сможет без проблем победить его снова. Чтобы сценарий оставался сложным, мы должны повысить сложность. Проще всего это сделать, снижая в последующих циклах все величины пауз между созданием врагов. Тогда враги станут появляться быстрее и неизбежно победят игрока в сценарии выживания.

Добавим в GameScenario ползунок конфигурации для управления ускорением за цикл. Это значение прибавляется к масштабу времени после каждого цикла только для уменьшения пауз. Например, при ускорении 0.5 первый цикл имеет скорость паузы ?1, второй цикл имеет скорость ?1.5, третий ?2, четвёртый ?2.5, и так далее.

	[SerializeField, Range(0f, 1f)]
	float cycleSpeedUp = 0.5f;

Теперь нужно добавить масштаб времени и в GameScenario.State. Он всегда изначально равен 1 и увеличивается на заданную величину ускорения после каждого цикла. Используем его для масштабирования Time.deltaTime перед продвижением по волне.

		float timeScale;

		EnemyWave.State wave;

		public State (GameScenario scenario) {
			this.scenario = scenario;
			cycle = 0;
			index = 0;
			timeScale = 1f;
			wave = scenario.waves[0].Begin();
		}

		public bool Progress () {
			float deltaTime = wave.Progress(timeScale * Time.deltaTime);
			while (deltaTime >= 0f) {
				if (++index >= scenario.waves.Length) {
					if (++cycle >= scenario.cycles && scenario.cycles > 0) {
						return false;
					}
					index = 0;
					timeScale += scenario.cycleSpeedUp;
				}
				wave = scenario.waves[index].Begin();
				deltaTime = wave.Progress(deltaTime);
			}
			return true;
		}


Три цикла с увеличивающейся скоростью создания врагов; ускорено в десять раз.

Хотите получать информацию о выходе новых туториалов? Следите за моей страницей на Patreon!

Репозиторий

Статья в PDF




К сожалению, не доступен сервер mySQL