Как стать автором
Обновить

Создание игры Tower Defense в Unity: башни и стрельба по врагам

Время на прочтение 17 мин
Количество просмотров 11K
Автор оригинала: Jasper Flick
[Первая и вторая части туториала]

  • Размещаем на поле башни.
  • Целимся во врагов при помощи физики.
  • Отслеживаем их, пока это возможно.
  • Стреляем в них лазерным лучом.

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

Туториал создавался в Unity 2018.3.0f2.


Зададим врагам жару.

Создание башни


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

Содержимое тайла


Башни — это ещё один тип содержимого тайла, поэтому добавим для них запись в GameTileContent.

public enum GameTileContentType {
	Empty, Destination, Wall, SpawnPoint, Tower€
}

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

	[SerializeField]
	GameTileContent towerPrefab = default;

	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			…
			case GameTileContentType.Tower€: return Get(towerPrefab);
		}
		…
	}

Но башни должны стрелять, поэтому их состояние нужно будет обновлять и им требуется собственный код. Создадим для этой цели класс Tower, расширяющий класс GameTileContent.

using UnityEngine;

public class Tower : GameTileContent {}

Можно сделать так, чтобы префаб башни имел собственный компонент, изменив тип поля фабрики на Tower. Так как класс по-прежнему считается GameTileContent, больше ничего менять не нужно.

	Tower towerPrefab = default;

Префаб


Создадим префаб для башни. Можно начать с дублирования префаба стены и замены его компонента GameTileContent на компонент Tower, а после изменить его тип на Tower. Чтобы башня соответствовала по размеру стенам, сохраните куб стены как основание башни. Затем поместите ещё один куб поверх него. Я задал ему масштаб 0.5. Поставьте на него ещё один куб, обозначающий турель, эта часть будет целиться и стрелять во врагов.



Три куба, образующих башню.

Турель будет поворачиваться, и поскольку она имеет коллайдер, её будет отслеживать физический движок. Но нам не нужно быть такими точными, потому что мы используем коллайдеры башен только для выбора ячеек. Это вполне можно делать приблизительно. Удалите коллайдер куба турели и измените коллайдер куба башни, чтобы он покрывал оба куба.



Коллайдер куба башни.

Башня будет стрелять лазерным лучом. Его можно визуализировать разными способами, но мы просто используем полупрозрачный куб, который растянем для образования луча. У каждой башни должен быть собственный луч, поэтому добавим его к префабу башни. Расположим ег внутри турели, чтобы по умолчанию он был скрыт, и придадим ему меньший масштаб, например 0.2. Сделаем его дочерним элементом корня префаба, а не куба турели.

laser beam

hierarchy

Скрытый куб лазерного луча.

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

color

no reflections

Материал лазерного луча.

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


Лазерный луч не взаимодействует с тенями.

Завершив создание префаба башни, добавим его в фабрику.


Фабрика с башней.

Размещение башен


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

	public void ToggleTower (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Tower€) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower€);
			if (!FindPaths()) {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
		}
	}

В Game.HandleTouch при зажатии клавиши shift переключаться будут не стены, а башни.

	void HandleTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			if (Input.GetKey(KeyCode.LeftShift)) {
				board.ToggleTower(tile);
			}
			else {
				board.ToggleWall(tile);
			}
		}
	}


Башни на поле.

Блокирование пути


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

	public bool BlocksPath =>
		Type == GameTileContentType.Wall || Type == GameTileContentType.Tower€;

Используем это свойство в GameTile.GrowPathTo вместо проверки типа содержимого.

	GameTile GrowPathTo (GameTile neighbor, Direction direction) {
		…
		return
			//neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
			neighbor.Content.BlocksPath ? null : neighbor;
	}


Теперь путь блокируют и стены, и башни.

Заменяем стены


Скорее всего, игрок будет часто заменять стены башнями. Ему будет неудобно сначала убирать стену, и к тому же в этот временно появившийся промежуток смогут проникать враги. Можно реализовать прямую замену, заставив GameBoard.ToggleTower проверять, находится ли в данный момент на тайле стена. Если да, то нужно сразу заменить её на башню. В таком случае нам не придётся искать другие пути, потому что тайл по-прежнему их блокирует.

	public void ToggleTower (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Tower) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			…
		}
		else if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower);
		}
	}

Целимся во врагов


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

Точка прицеливания


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

Мы не можем прикрепить коллайдер к корневому объекту врага, потому что он не всегда совпадает с позицией модели и заставит башню целиться в землю. То есть нужно разместить коллайдер где-то на модели. Физический движок даст нам ссылку на этот объект, которую мы сможем использовать для прицеливания, но нам ещё нужен будет и доступ к компоненту Enemy корневого объекта. Чтобы упростить задачу, давайте создадим компонент TargetPoint. Дадим ему свойство для приватного задания и публичного получения компонента Enemy, и ещё одно свойство для получения его позиции в мире.

using UnityEngine;

public class TargetPoint : MonoBehaviour {

	public Enemy Enemy€ { get; private set; }

	public Vector3 Position => transform.position;
}

Дадим ему метод Awake, задающий ссылку на его компонент Enemy. Перейти непосредственно к корневому объекту можно при помощи transform.root. Если компонент Enemy не существует, то тогда мы совершили ошибку при создании врага, поэтому давайте добавим для этого утверждение.

	void Awake () {
		Enemy€ = transform.root.GetComponent<Enemy>();
		Debug.Assert(Enemy€ != null, "Target point without Enemy root!", this);
	}

Кроме того, коллайдер должен быть прикреплён к тому же игровому объекту, к которому прикреплён TargetPoint.

		Debug.Assert(Enemy€ != null, "Target point without Enemy root!", this);
		Debug.Assert(
			GetComponent<SphereCollider>() != null,
			"Target point without sphere collider!", this
		);

Добавим к кубу префаба врага компонент и коллайдер. Это заставит башни целиться в центр куба. Используем сферический коллайдер с радиусом 0.25. Куб имеет масштаб 0.5, поэтому истинный радиус коллайдера будет равен 0.125. Благодаря этому враг должен будет визуально пересечь круг дальности башни, и только спустя какое-то время становиться настоящей цель. На размер коллайдера также влияет случайный масштаб врага, поэтому его размер в игре тоже будет немного варьироваться.


inspector

Враг с точкой для прицеливания и коллайдером на кубе.

Слой врагов


Башни волнуют только враги, и они не целятся ни во что другое, поэтому мы поместим всех врагов в отдельный слой. Воспользуемся слоем 9. Измените его название на Enemy в окне Layers & Tags, которое можно открыть через опцию Edit Layers в раскрывающемся меню Layers в правом верхнем углу редактора.


Слой 9 будет использоваться для врагов.

Этот слой нужен только для распознавания врагов, а не для физических взаимодействий. Давайте укажем на это, отключив их в Layer Collision Matrix, которая находится в панели Physics параметров проекта.


Матрица коллизий слоёв.

Убедитесь, что игровой объект точки прицеливания находится на нужном слое. Остальная часть префаба врага может быть на других слоях, но проще будет всё согласовать и поместить весь префаб в слой Enemy. Если вы измените слой корневого объекта, то вам будет предложено изменить слой и для всех его дочерних объектов.


Враг на нужном слое.

Давайте добавим утверждение о том, что TargetPoint действительно находится на нужном слое.

	void Awake () {
		…
		Debug.Assert(gameObject.layer == 9, "Target point on wrong layer!", this);
	}

Кроме того, действия игрока должны игнорировать вражеские коллайдеры. Это можно реализовать, добавив аргумент маски слоя к Physics.Raycast в GameBoard.GetTile. У этого метода есть форма, получающая в качестве дополнительных аргументов расстояние до луча и маску слоя. Передадим ему максимальное расстояние и маску слоя по умолчанию, то есть 1.

	public GameTile GetTile (Ray ray) {
		if (Physics.Raycast(ray, out RaycastHit hit, float.MaxValue, 1)) {
			…
		}
		return null;
	}

Разве маска слоя не должна равняться 0?
Индекс слоя по умолчанию равен нулю, но мы передаём маску слоя. Маска меняет отдельные биты целого числа на 1, если слой нужно включить. В данном случае нужно задать только первый бит, то есть самый младший, а значит, 20, что равняется 1.

Обновление содержимого тайлов


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

	public virtual void GameUpdate () {}

Заставим Tower переопределить его, пусть пока просто выводит в консоль, что ищет цель.

	public override void GameUpdate () {
		Debug.Log("Searching for target...");
	}

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

	List<GameTileContent> updatingContent = new List<GameTileContent>();
	
	…
	
	public void GameUpdate () {
		for (int i = 0; i < updatingContent.Count; i++) {
			updatingContent[i].GameUpdate();
		}
	}

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

	public void ToggleTower (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Tower) {
			updatingContent.Remove(tile.Content);
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower);
			//if (!FindPaths()) {
			if (FindPaths()) {
				updatingContent.Add(tile.Content);
			}
			else {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
		}
		else if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower);
			updatingContent.Add(tile.Content);
		}
	}

Чтобы это заработало, сейчас нам достаточно просто обновлять поле в Game.Update. Поле мы будем обновлять после врагов. Благодаря этому башни смогут целиться точно туда, где находятся враги. Если бы мы сделали иначе, то башни бы целились туда, где были враги в прошлом кадре.

	void Update () {
		…
		enemies.GameUpdate();
		board.GameUpdate();
	}

Дальность прицеливания


У башен есть ограниченный радиус прицеливания. Сделаем его настраиваемым, добавив поле в класс Tower. Расстояние измеряется от центра тайла башни, поэтому при дальности 0.5 оно будет покрывать только собственный тайл. Следовательно разумным минимумом и стандартной дальностью будет значение 1.5, покрывающее большинство соседних тайлов.

	[SerializeField, Range(1.5f, 10.5f)]
	float targetingRange = 1.5f;


Дальность прицеливания 2.5.

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

	void OnDrawGizmosSelected () {
		Gizmos.color = Color.yellow;
		Vector3 position = transform.localPosition;
		position.y += 0.01f;
		Gizmos.DrawWireSphere(position, targetingRange);
	}


Гизмо дальности прицеливания.

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

[SelectionBase]
public class GameTileContent : MonoBehaviour { … }

Захват цели


Добавим классу Tower поле TargetPoint, чтобы он мог отслеживать свою захваченную цель. Затем изменим GameUpdate, чтобы он вызывал новый метод AquireTarget, возвращающий информацию о том, нашёл ли он цель. При обнаружении он будет выводить сообщение в консоль.

	TargetPoint target;

	public override void GameUpdate () {
		if (AcquireTarget()) {
			Debug.Log("Acquired target!");
		}
	}

В AcquireTarget получим все доступные цели, вызвав Physics.OverlapSphere с позицией башни и дальностью в качестве аргументов. Результатом будет массив Collider, содержащий все коллайдеры, находящиеся в контакте со сферой. Если длина массива положительна, то существует хотя бы одна точка прицеливания, и мы просто выбираем первую. Возьмём её компонент TargetPoint, который должен всегда существовать, присвоим его полю target и сообщим об успехе. В противном случае очистим target и сообщим о неудаче.

	bool AcquireTarget () {
		Collider[] targets = Physics.OverlapSphere(
			transform.localPosition, targetingRange
		);
		if (targets.Length > 0) {
			target = targets[0].GetComponent<TargetPoint>();
			Debug.Assert(target != null, "Targeted non-enemy!", targets[0]);
			return true;
		}
		target = null;
		return false;
	}

Мы гарантированно получим правильные точки прицеливания, если будем учитывать коллайдеры только на слое врагов. Это слой 9, поэтому передадим соответствующую маску слоя.

	const int enemyLayerMask = 1 << 9;

	…

	bool AcquireTarget () {
		Collider[] targets = Physics.OverlapSphere(
			transform.localPosition, targetingRange, enemyLayerMask
		);
		…
	}

Как работает эта битовая маска?
Так как слой врагов имеет индекс 9, десятый бит битовой маски должен иметь значение 1. Этому соответствует целое число 29, то есть 512. Но такая запись битовой маски неинтуитивна. Мы можем также записать двоичный литерал, например 0b10_0000_0000, но тогда нам придётся считать нули. В данном случае наиболее удобной записью будет использование оператора сдвига влево <<, сдвигающего биты влево. что соответствует числу в степени двойки.

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

	void OnDrawGizmosSelected () {
		…
		if (target != null) {
			Gizmos.DrawLine(position, target.Position);
		}
	}


Визуализация целей.

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

Фиксация на цели


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

	bool TrackTarget () {
		if (target == null) {
			return false;
		}
		return true;
	}

Вызовем этот метод в GameUpdate и только при возврате false будем вызывать AcquireTarget. Если метод вернул true, то у нас есть цель. Это можно сделать, поместив оба вызова методов в проверку if с оператором OR, потому что если первый операнд вернёт true, то второй не будет проверяться, и вызов будет пропущен. Оператор AND действует схожим образом.

	public override void GameUpdate () {
		if (TrackTarget() || AcquireTarget()) {
			Debug.Log("Locked on target!");
		}
	}


Отслеживание целей.

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

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

	bool TrackTarget () {
		if (target == null) {
			return false;
		}
		Vector3 a = transform.localPosition;
		Vector3 b = target.Position;
		if (Vector3.Distance(a, b) > targetingRange) {
			target = null;
			return false;
		}
		return true;
	}

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

		if (Vector3.Distance(a, b) > targetingRange + 0.125f) { … }

Это даёт нам правильные результаты, но только если масштаб врага не изменён. Так как мы даём каждому врагу случайный масштаб, нужно учитывать его при изменении дальности. Для этого мы должны запоминать масштаб, данный Enemy и открывать его при помощи свойства-геттера.

	public float Scale { get; private set; }

	…

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

Теперь мы можем проверять в Tower.TrackTarget правильную дальность.

		if (Vector3.Distance(a, b) > targetingRange + 0.125f * target.Enemy€.Scale) { … }

Синхронизируем физику


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


Неправильное прицеливание.

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

Можно включить мгновенную синхронизацию, выполняемую при изменении преобразований объекта, присвоив Physics.autoSyncTransforms значение true. Но по умолчанию оно отключено, потому что гораздо эффективнее синхронизировать всё вместе и при необходимости. В нашем случае синхронизация требуется только при обновлении состояния башен. Мы можем выполнять её, вызывая Physics.SyncTransforms между обновлениями врагов и поля в Game.Update.

	void Update () {
		…
		enemies.GameUpdate();
		Physics.SyncTransforms();
		board.GameUpdate();
	}

Игнорируем высоту


По сути, наш игровой процесс происходит в 2D. Поэтому давайте изменим Tower так, чтобы при прицеливании и отслеживании он учитывал только координаты X и Z. Физический движок работает в 3D-пространстве, но по сути мы можем выполнять проверку AcquireTarget в 2D: растянем сферу вверх, чтобы она покрывала все коллайдеры, вне зависимости от их позиции по вертикали. Это можно сделать, использовав вместо сферы капсулу, вторая точка которой будет в нескольких единицах над землёй (допустим, в трёх).

	bool AcquireTarget () {
		Vector3 a = transform.localPosition;
		Vector3 b = a;
		b.y += 3f;
		Collider[] targets = Physics.OverlapCapsule(
			a, b, targetingRange, enemyLayerMask
		);
		…
	}

Разве нельзя использовать физический 2D-движок?
Проблема в том, что наша игра проходит в плоскости XZ, а физический 2D-движок работает в плоскости XY. Мы можем заставить его работать, или изменив ориентацию всей игры, или создав отдельное 2D-представление только для физики. Но легче просто использовать 3D-физику.

Нужно также изменить TrackTarget. Конечно, мы можем использовать 2D-векторы и Vector2.Distance, но давайте проведём вычисления самостоятельно и вместо этого будем сравнивать квадраты расстояний, этого будет достаточно. Так мы избавимся от операции вычисления квадратного корня.

	bool TrackTarget () {
		if (target == null) {
			return false;
		}
		Vector3 a = transform.localPosition;
		Vector3 b = target.Position;
		float x = a.x - b.x;
		float z = a.z - b.z;
		float r = targetingRange + 0.125f * target.Enemy€.Scale;
		if (x * x + z * z > r * r) {
			target = null;
			return false;
		}
		return true;
	}

Как работают эти математические вычисления?
В них для вычисления 2D-расстояния используется теорема Пифагора, но без расчёта квадратного корня. Вместо этого вычисляется квадрат радиуса, поэтому в результате мы сравниваем квадраты длин. Этого достаточно, потому что нам нужно проверять только относительную длину, а не точную разность.

Избегаем выделения памяти


Недостаток использования Physics.OverlapCapsule заключается в том, что на каждый вызов он выделяет новый массив. Этого можно избежать, выделив массив один раз и вызывая альтернативный метод OverlapCapsuleNonAlloc с массивом в качестве дополнительного аргумента. Длина передаваемого массива определяет количество получаемых результатов. Все потенциальные цели за пределами массива отбрасываются. Мы всё равно будем использовать только первый элемент, поэтому нам хватит массива длиной 1.

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

	static Collider[] targetsBuffer = new Collider[1];

	…

	bool AcquireTarget () {
		Vector3 a = transform.localPosition;
		Vector3 b = a;
		b.y += 2f;
		int hits = Physics.OverlapCapsuleNonAlloc(
			a, b, targetingRange, targetsBuffer, enemyLayerMask
		);
		if (hits > 0) {
			target = targetsBuffer[0].GetComponent<TargetPoint>();
			Debug.Assert(target != null, "Targeted non-enemy!", targetsBuffer[0]);
			return true;
		}
		target = null;
		return false;
	}

Стреляем во врагов


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

Прицеливаемся турелью


Чтобы направить турель на цель, классу Tower нужно иметь ссылку на компонент Transform турели. Добавим для этого поле конфигурации и подключим его в префабе башни.

	[SerializeField]
	Transform turret = default;


Присоединённая турель.

Если в GameUpdate есть настоящая цель, то мы должны стрелять в неё. Поместим код стрельбы в отдельный метод. Заставим его вращать турель в сторону цели, вызывая его метод Transform.LookAt с точкой прицеливания в качестве аргумента.

	public override void GameUpdate () {
		if (TrackTarget() || AcquireTarget()) {
			//Debug.Log("Locked on target!");
			Shoot();
		}
	}

	void Shoot () {
		Vector3 point = target.Position;
		turret.LookAt(point);
	}


Просто целимся.

Стреляем лазером


Для позиционирования лазерного луча классу Tower тоже нужна ссылка на него.

	[SerializeField]
	Transform turret = default, laserBeam = default;


Подключили лазерный луч.

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

	void Shoot () {
		Vector3 point = target.Position;
		turret.LookAt(point);
		laserBeam.localRotation = turret.localRotation;
	}

Во-вторых мы отмасштабируем лазерный луч так, чтобы его длина была равна расстоянию между локальной точкой начала координат турели и точкой прицеливания. Мы масштабируем его по оси Z, то есть локальной оси, направленной в сторону цели. Чтобы сохранить исходный масштаб по XY, запишем исходный масштаб при пробуждении (Awake) турели.

	Vector3 laserBeamScale;

	void Awake () {
		laserBeamScale = laserBeam.localScale;
	}

	…

	void Shoot () {
		Vector3 point = target.Position;
		turret.LookAt(point);
		laserBeam.localRotation = turret.localRotation;

		float d = Vector3.Distance(turret.position, point);
		laserBeamScale.z = d;
		laserBeam.localScale = laserBeamScale;
	}

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

		laserBeam.localScale = laserBeamScale;
		laserBeam.localPosition =
			turret.localPosition + 0.5f * d * laserBeam.forward;


Стрельба лазерными лучами.

Разве нельзя сделать лазерный луч дочерним элементом турели?
Если бы мы сделали это, то нам не пришлось бы поворачивать лазерный луч отдельно, и не понадобился бы его вектор forward. Однако на него бы влиял масштаб турели, поэтому его пришлось бы компенсировать. Проще хранить их по отдельности.

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

	public override void GameUpdate () {
		if (TrackTarget() || AcquireTarget()) {
			Shoot();
		}
		else {
			laserBeam.localScale = Vector3.zero;
		}
	}


Простаивающие башни не стреляют.

Здоровье врагов


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

	float Health { get; set; }
	
	…

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

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

	public void ApplyDamage (float damage) {
		Debug.Assert(damage >= 0f, "Negative damage applied.");
		Health -= damage;
	}

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

	public bool GameUpdate () {
		if (Health <= 0f) {
			OriginFactory.Reclaim(this);
			return false;
		}

		…
	}

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

Урон в секунду


Теперь нам нужно определить, сколько урона будет наносить лазер. Для этого добавим к Tower поле конфигурации. Так как лазерный луч наносит непрерывный урон, мы выразим его как урон в секунду (damage per second). В Shoot приложим его к компоненту Enemy цели с умножением на дельту времени.

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

	…
	
	void Shoot () {
		…

		target.Enemy.ApplyDamage(damagePerSecond * Time.deltaTime);
	}

inspector


Урон каждой башни — 20 единиц в секунду.

Прицеливание случайным образом


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

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

	static Collider[] targetsBuffer = new Collider[100];

Теперь вместо выбора первой потенциальной цели мы будем выбирать из массива случайный элемент.

	bool AcquireTarget () {
		…
		if (hits > 0) {
			target =
				targetsBuffer[Random.Range(0, hits)].GetComponent<TargetPoint>();
			…
		}
		target = null;
		return false;
	}


Случайное прицеливание.

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

Итак, в нашей игре жанра «башенная защита» наконец-то появились башни. В следующей части игра ещё больше примет свои окончательные очертания.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+19
Комментарии 1
Комментарии Комментарии 1

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн