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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
[Первая, вторая и третья части туториала]

  • Поддержка разных типов башен.
  • Создание башни-мортиры.
  • Вычисление параболических траекторий.
  • Запуск взрывающихся снарядов.

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

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


Враги подвергаются бомбардировке.

Типы башен


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

Абстрактная башня


Обнаружение и отслеживание цели — это функциональность, которую может использовать любая башня, поэтому мы поместим её в абстрактный базовый класс башен. Для этого мы просто воспользуемся классом Tower, но для начала дублируем его содержимое для дальнейшего использования в конкретный класс LaserTower. Затем удалим из Tower весь код, относящийся к лазеру. Башня может и не отслеживать конкретную цель, поэтому удалим поле target и изменим AcquireTarget и TrackTarget так, чтобы в качестве параметра ссылки использовался выходной параметр. Затем удалим из OnDrawGizmosSelected визуализацию цели, но оставим дальность прицеливания, потому что она используется для всех башен.

using UnityEngine;

public abstract class Tower : GameTileContent {

	const int enemyLayerMask = 1 << 9;

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

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

	protected bool AcquireTarget (out TargetPoint target) {
		…
	}

	protected bool TrackTarget (ref TargetPoint target) {
		…
	}

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

Изменим дублирующий класс, чтобы он превратился в LaserTower, расширяющий Tower, и использовал функциональность его базового класса, избавившись от дублирующего кода.

using UnityEngine;

public class LaserTower : Tower {

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

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

	TargetPoint target;

	Vector3 laserBeamScale;

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

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

	void Shoot () {
		…
	}

}

Затем обновим префаб лазерной башни, чтобы она использовала новый компонент.


Компонент лазерной башни.

Создание конкретного типа башен


Чтобы иметь возможность выбора размещаемых на поле башен, мы добавим перечисление TowerType, аналогичное GameTileContentType. Создадим поддержку уже имеющейся лазерной башни и башни-мортиры, которую создадим позже.

public enum TowerType {
	Laser, Mortar
}

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

	public abstract TowerType TowerType€ { get; }

Переопределим его в LaserTower, чтобы оно возвращало правильный тип.

	public override TowerType TowerType€ => TowerType.Laser;

Далее изменим GameTileContentFactory так, чтобы фабрика могла производить башню нужного типа. Мы реализуем это при помощи массива башен и добавления альтернативного публичного метода Get, имеющего параметр TowerType. Для проверки того, что массив настроен правильно, воспользуемся утверждениями (assertions). Другой публичный метод Get теперь будет относиться только к содержимому тайлов без башен.

	[SerializeField]
	Tower[] towerPrefabs = default;

	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			…
		}
		Debug.Assert(false, "Unsupported non-tower type: " + type);
		return null;
	}

	public GameTileContent Get (TowerType type) {
		Debug.Assert((int)type < towerPrefabs.Length, "Unsupported tower type!");
		Tower prefab = towerPrefabs[(int)type];
		Debug.Assert(type == prefab.TowerType€, "Tower prefab at wrong index!");
		return Get(prefab);
	}

Логично будет возвращать наиболее конкретный тип, поэтому в идеале возвращаемым типом нового метода Get должен быть Tower. Но приватный метод Get, используемый для создания экземпляров префаба, возвращает GameTileContent. Здесь можно или выполнить преобразование, или сделать приватный метод Get обобщённым (generic). Давайте выберем второй вариант.

	public Tower Get (TowerType type) {
		…
	}
	
	T Get<T> (T prefab) where T : GameTileContent {
		T instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		return instance;
	}

Пока у нас есть только лазерная башня, сделаем её единственным элементом массива башен фабрики.


Массив префабов башен.

Создание экземпляров конкретных типов башен


Для создания башни конкретного типа изменим GameBoard.ToggleTower так, чтобы он требовал параметр TowerType и передавал его фабрике.

	public void ToggleTower (GameTile tile, TowerType towerType) {
		if (tile.Content.Type == GameTileContentType.Tower€) {
			…
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(towerType);
			…
		}
		else if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(towerType);
			updatingContent.Add(tile.Content);
		}
	}

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

		if (tile.Content.Type == GameTileContentType.Tower€) {
			updatingContent.Remove(tile.Content);
			if (((Tower)tile.Content).TowerType€ == towerType) {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
			else {
				tile.Content = contentFactory.Get(towerType);
				updatingContent.Add(tile.Content);
			}
		}

Теперь Game должен отслеживать тип переключаемой башни. Мы просто обозначим каждый тип башен числом. Лазерная башня — это 1, она же будет башней по умолчанию, а башня-мортира — 2. Нажимая цифровые клавиши, мы будем выбирать соответствующий тип башен.

	TowerType selectedTowerType;

	…

	void Update () {
		…
		if (Input.GetKeyDown(KeyCode.G)) {
			board.ShowGrid = !board.ShowGrid;
		}

		if (Input.GetKeyDown(KeyCode.Alpha1)) {
			selectedTowerType = TowerType.Laser;
		}
		else if (Input.GetKeyDown(KeyCode.Alpha2)) {
			selectedTowerType = TowerType.Mortar;
		}

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

Башня-мортира


Разместить башню-мортиру пока не удастся, потому что у неё пока нет префаба. Начнём с создания минимального типа MortarTower. Мортиры имеют частоту стрельбы, для обозначения которой можно использовать поле конфигурации «выстрелы в секунду». Кроме того, нам нужна будет ссылка на мортиру, чтобы она могла прицеливаться.

using UnityEngine;

public class MortarTower : Tower {

	[SerializeField, Range(0.5f, 2f)]
	float shotsPerSecond = 1f;

	[SerializeField]
	Transform mortar = default;

	public override TowerType TowerType€ => TowerType.Mortar;
}

Теперь создадим префаб для башни-мортиры. Это можно сделать, дублировав префаб лазерной башни и заменив её компонент башни. Затем избавимся от объектов башни и лазерного луча. Переименуем turret в mortar, сместим её вниз, чтобы она стояла поверх основания, придадим ей светло-серый цвет и прикрепим её. Коллайдер мортиры мы можем оставить, в этом случае воспользовавшись отдельным объектом, который является простым коллайдером, наложенным на ориентацию мортиры по умолчанию. Я присвоил мортире дальность 3.5 и частоту 1 выстрел в секунду.

scene

hierarchy

inspector

Префаб башни-мортиры.

Почему они называются мортирами (mortar)?
Первые разновидности этого оружия по сути были железными чашами, похожими на ступы (mortar), в которых при помощи пестика перемалывались ингредиенты.

Добавим префаб мортиры в массив фабрики, чтобы можно было размещать башни-мортиры на поле. Однако пока они ничего не делают.

inspector

scene

Два типа башен, один из них неактивный

Вычисление траекторий


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

Прицеливание по горизонтали


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

Добавим в MortarTower метод GameUpdate, который всегда вызывает метод Launch. Вместо выстрела настоящим снарядом мы пока будем визуализировать математические вычисления. Точка выстрела — это позиция мортиры в мире, которая находится чуть выше земли. Разместим точку цели в трёх единицах от неё по оси X, и обнулим компоненту Y, потому что мы всегда целимся в землю. Затем покажем точки, отрисовав между ними жёлтую линию при помощи вызова Debug.DrawLine. Линия будет видимой в режиме сцены один кадр, но этого достаточно, потому что в каждом кадре мы рисуем новую линию.

	public override void GameUpdate () {
		Launch();
	}

	public void Launch () {
		Vector3 launchPoint = mortar.position;
		Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z);

		Debug.DrawLine(launchPoint, targetPoint, Color.yellow);
	}


Целимся в точку, фиксированную относительно башни.

С помощью этой линии мы можем задать прямоугольный треугольник. Его верхняя очка находится в позиции мортиры. Относительно мортиры это $\begin{bmatrix}0\\0\end{bmatrix}$. Точка ниже, в основании башни — это $\begin{bmatrix}0\\y\end{bmatrix}$, а точка в цели — это $\begin{bmatrix}x\\y\end{bmatrix}$, где $x$ равен 3, а $y$ — это отрицательная позиция мортиры по вертикали. Нам нужно отслеживать эти два значения.

		Vector3 launchPoint = mortar.position;
		Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z);

		float x = 3f;
		float y = -launchPoint.y;


Треугольник прицеливания.

В общем случае цель может быть в любом месте области дальности башни, поэтому Z тоже нужно учитывать. Однако треугольник прицеливания всё равно остаётся двухмерным, он просто поворачивается вокруг оси Y. Чтобы проиллюстрировать это, мы добавим в Launch параметр вектора относительного смещения и будем вызывать его с четырьмя смещениями по XZ: $\begin{bmatrix}3\\0\end{bmatrix}$, $\begin{bmatrix}0\\1\end{bmatrix}$, $\begin{bmatrix}1\\1\end{bmatrix}$ и $\begin{bmatrix}3\\1\end{bmatrix}$. Когда точка прицеливания становится равной точке выстрела плюс это смещение, а затем её координата Y становится равной нулю.

	public override void GameUpdate () {
		Launch(new Vector3(3f, 0f, 0f));
		Launch(new Vector3(0f, 0f, 1f));
		Launch(new Vector3(1f, 0f, 1f));
		Launch(new Vector3(3f, 0f, 1f));
	}

	public void Launch (Vector3 offset) {
		Vector3 launchPoint = mortar.position;
		Vector3 targetPoint = launchPoint + offset;
		targetPoint.y = 0f;
		
		…
	}

Теперь x треугольника прицеливания равен длине 2D-вектора, указывающего от основания башни в точку прицеливания. Нормализировав этот вектор, мы также получим вектор направления XZ, который можно использовать для выравнивания треугольника. Можно показать его, отрисовав нижнюю часть треугольника как белую линию, полученную из направления и x.

		Vector2 dir;
		dir.x = targetPoint.x - launchPoint.x;
		dir.y = targetPoint.z - launchPoint.z;
		float x = dir.magnitude;
		float y = -launchPoint.y;
		dir /= x;

		Debug.DrawLine(launchPoint, targetPoint, Color.yellow);
		Debug.DrawLine(
			new Vector3(launchPoint.x, 0.01f, launchPoint.z),
			new Vector3(
				launchPoint.x + dir.x * x, 0.01f, launchPoint.z + dir.y * x
			),
			Color.white
		);


Выровненные треугольники прицеливания.

Угол выстрела


Далее нам следует выяснить угол, под которым нужно стрелять снарядом. Необходимо вывести его из физики траектории снаряда. Мы не будем учитывать лобовое сопротивление, ветер и другие помехи, только скорость выстрела $v$ и гравитацию $g = 9.81$.

Смещение $d$ снаряда находится на одной линии с треугольником прицеливания и может быть описано двумя компонентами. С горизонтальным смещением всё просто: это $d_x=v_xt$, где $t$ — время после выстрела. С вертикальным компонентом всё похоже, то он подвержен отрицательному ускорению из-за гравитации, поэтому имеет вид $d_y=v_yt-(g t^2)/2$.

Как выполняется вычисление смещения?
Скорость $v$ определяется в расстоянии на секунду, поэтому умножив скорость на длительность $t$, мы получим расстояние $d=vt$. Когда задействовано ускорение $a$, скорость непостоянна. Ускорение — это изменение скорости за секунду, то есть это расстояние на секунду в квадрате. В любой момент времени скорость равна $v=at$. В нашем случае имеется постоянное ускорение $a=-g$, поэтому мы можем разделить его пополам, чтобы получить среднюю скорость, и умножить на время, чтобы найти смещение $d=(at^2)/2$, вызванное гравитацией.

Мы выстреливаем снаряды с одинаковой скоростью $s$, которая не зависит от угла выстрела $\theta$ (тета). То есть $v_x=s\cos\theta$ и $v_y=s\sin\theta$.


Вычисление скорости выстрела.

Выполнив подстановку, мы получим $d_x=st\cos\theta$ и $d_y=st\sin\theta-(g t^2)/2$.

Снаряд выстреливается так, что его время полёта $t$ является точной величиной, необходимой для достижения цели. Так как с горизонтальным смещением работать проще, мы можем выразить время как $t=d_x/v_x$. В конечной точке $d_x=x$, то есть $t=x/(s\cos\theta)$. Это значит, что $y=x\tan\theta-(gx^2)/(2s^2\cos^2\theta)$.

Как получить уравнение y?
$y=d_y=s(x/(s\cos\theta))\sin\theta-(g (x/(s\cos\theta))^2)/2=x\sin\theta/\cos\theta-(gx^2)/(2s^2\cos^2\theta)$ и $\tan\theta=\sin\theta/\cos\theta$.

С помощью этого уравнения мы найдём $\tan\theta=(s^2+-\sqrt{(s^4-g(gx^2+2ys^2))})/(gx)$.
Как получить уравнение tan θ?
Сначала мы воспользуемся тригонометрическим тождеством $\sec\theta=1/\cos\theta$ и $1+\tan^2\theta=\sec^2\theta$, чтобы прийти к $y=x\tan\theta-(gx^2)/(2s^2)(1+\tan^2\theta)=-(gx^2)/(2s^2)\tan^2\theta+x\tan\theta-(gx^2)/(2s^2)$.

Это выражение вида $au^2+bu+c=0$, где $u=\tan\theta$, $a=-(gx^2)/(2s^2)$, $b=x$, а $c=a-y$.

Мы можем решить его при помощи формулы корней квадратного уравнения $u=(-b+-\sqrt{(b^2-4ac)})/(2a)$.

После её подстановки уравнение станет запутанным, но можно упростить его, домножив на $m=s^2/x$ так, чтобы получить $\tan\theta=(-mb+-m\sqrt{r})/(2ma)$, где $r=b^2-4ac$.

При этом получим $\tan\theta=(s^2+-\sqrt{(m^2r)})/(gx)$.

В результате $m^2r=(s^4/x^2)r=s^4+2gs^2c=s^4-g^2x^2-2gys^2=s^4-g(gx^2+2ys^2)$.

Существует два возможных угла, потому что можно целиться высоко или низко. Низкая траектория быстрее, потому что она ближе к прямой линии до цели. Но высокая траектория выглядит интереснее, поэтому мы выберем её. Это значит, что нам достаточно использовать только наибольшее решение $\tan\theta=(s^2+\sqrt{(s^4-g(gx^2+2ys^2))})/(gx)$. Вычислим его, а также $\cos\theta$ с $\sin\theta$, потому что они нужны нам для получения вектора скорости выстрела. Для этого нужно преобразовать $\tan\theta$ в радианный угол при помощи Mathf.Atan. Для начала давайте будем использовать постоянную скорость выстрела 5.

		float x = dir.magnitude;
		float y = -launchPoint.y;
		dir /= x;

		float g = 9.81f;
		float s = 5f;
		float s2 = s * s;

		float r = s2 * s2 - g * (g * x * x + 2f * y * s2);
		float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x);
		float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta));
		float sinTheta = cosTheta * tanTheta;

Давайте визуализируем траекторию, отрисовав десять синих отрезков, показывающих первую секунду полёта.

		float sinTheta = cosTheta * tanTheta;

		Vector3 prev = launchPoint, next;
		for (int i = 1; i <= 10; i++) {
			float t = i / 10f;
			float dx = s * cosTheta * t;
			float dy = s * sinTheta * t - 0.5f * g * t * t;
			next = launchPoint + new Vector3(dir.x * dx, dy, dir.y * dx);
			Debug.DrawLine(prev, next, Color.blue);
			prev = next;
		}


Траектории полёта по параболе длительностью одну секунду.

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

Скорость выстрела


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

		float s = 4f;


Скорость выстрела снижена до 4.

Их траектории теперь завершены, но две другие пропали. Так случилось, потому что скорости выстрела теперь недостаточно для достижения этих точек. В таких случаях решений для $\tan\theta$ нет, то есть у нас получается квадратный корень отрицательного числа, приводящие к значениям NaN и пропаданию линий. Мы можем распознавать это, проверяя $r$ на отрицательность.

		float r = s2 * s2 - g * (g * x * x + 2f * y * s2);
		Debug.Assert(r >= 0f, "Launch velocity insufficient for range!");

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

При максимальной дальности $r=0$, то есть для $\tan\theta$ есть только одно решение, соответствующее низкой траектории. Это значит, что мы узнаем требуемую скорость выстрела $s=\sqrt{(g(y+\sqrt{(x^2+y^2)}))}$.

Как вывести это уравнение для s?
Нужно решить $s^4-g(gx^2+2ys^2)=s^4-2gys^2-g^2x^2=0$ для $s$.

Это выражение вида $au^2+bu+c=0$, где $u=s^2$, $a=1$, $b=-2gy$ и $c=-g^2x^2$.

Можно решить его при помощи упрощённой формулы корней квадратного уравнения $u=(-b+-\sqrt{(b^2-4c)})/2$.

После подстановки получаем $s^2=(2gy+-\sqrt{(4g^2y^2+4g^2x^2)})/2=gy+-g\sqrt{(x^2+y^2)}$.

Нам нужно положительное решение, поэтому приходим к $s^2 = g(y+\sqrt{(x^2+y^2)})$.

Определять требуемую скорость нам нужно только при пробуждении (Awake) мортиры или когда мы изменяем её дальность в режиме Play. Поэтому будем отслеживать её при помощи поля и вычислять её в Awake и OnValidate.

	float launchSpeed;

	void Awake () {
		OnValidate();
	}

	void OnValidate () {
		float x = targetingRange;
		float y = -mortar.position.y;
		launchSpeed = Mathf.Sqrt(9.81f * (y + Mathf.Sqrt(x * x + y * y)));
	}

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

		float x = targetingRange + 0.25001f;

Далее применим выведенное уравнение скорости выстрела в Launch.

		float s = launchSpeed;


Применяем вычисленную скорость к дальности прицеливания 3.5.

Стрельба


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

	public void Launch (TargetPoint target) {
		Vector3 launchPoint = mortar.position;
		Vector3 targetPoint = target.Position;
		targetPoint.y = 0f;

		…
	}

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

	float launchProgress;

	…

	public override void GameUpdate () {
		launchProgress += shotsPerSecond * Time.deltaTime;
		while (launchProgress >= 1f) {
			if (AcquireTarget(out TargetPoint target)) {
				Launch(target);
				launchProgress -= 1f;
			}
			else {
				launchProgress = 0.999f;
			}
		}
	}

Мы не отслеживаем цели между выстрелами, но нам нужно правильно поворачивать мортиру при выстрелах. Можно использовать горизонтальное направление выстрела для поворота мортиры по горизонтали при помощи Quaternion.LookRotation. Также нам нужно при помощи $\tan\theta$ применить угол выстрела для компонента Y вектора направления. Это сработает, потому что горизонтальное направление имеет длину 1, то есть $\tan\theta=\sin\theta$.


Разложение вектора поворота взгляда.

		float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x);
		float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta));
		float sinTheta = cosTheta * tanTheta;
		
		mortar.localRotation =
			Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y));

Чтобы траектории выстрелов по-прежнему было видно, можно добавить в Debug.DrawLine параметр, позволяющий им отрисовываться длительно.

		Vector3 prev = launchPoint, next;
		for (int i = 1; i <= 10; i++) {
			…
			Debug.DrawLine(prev, next, Color.blue, 1f);
			prev = next;
		}

		Debug.DrawLine(launchPoint, targetPoint, Color.yellow, 1f);
		Debug.DrawLine(
			…
			Color.white, 1f
		);


Прицеливание.

Снаряды


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

War Factory


Нам нужна фабрика для создания экземпляров объектов снарядов. Находясь в воздухе, снаряды существуют сами по себе и больше не зависят от выстрелившей ими мортиры. Поэтому их не должна обрабатывать башня-мортира, и фабрика содержимого тайлов тоже плохо для этого подходит. Давайте создадим создадим для всего, что связано с вооружениями, новую фабрику и назовём её war factory. Во-первых, создадим абстрактный WarEntity со свойством OriginFactory и методом Recycle.

using UnityEngine;

public abstract class WarEntity : MonoBehaviour {

	WarFactory originFactory;

	public WarFactory OriginFactory {
		get => originFactory;
		set {
			Debug.Assert(originFactory == null, "Redefined origin factory!");
			originFactory = value;
		}
	}

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

Затем создадим конкретную сущность Shell для снарядов.

using UnityEngine;

public class Shell : WarEntity { }

Затем создадим саму WarFactory, которая будет создавать снаряд при помощи публичного свойства-геттера.

using UnityEngine;

[CreateAssetMenu]
public class WarFactory : GameObjectFactory {

	[SerializeField]
	Shell shellPrefab = default;

	public Shell Shell€ => Get(shellPrefab);

	T Get<T> (T prefab) where T : WarEntity {
		T instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		return instance;
	}

	public void Reclaim (WarEntity entity) {
		Debug.Assert(entity.OriginFactory == this, "Wrong factory reclaimed!");
		Destroy(entity.gameObject);
	}
}

Создадим префаб для снаряда. Я использовал простой куб с одинаковым масштабом 0.25 и тёмный материал, а также компонент Shell. Затем создадим ассет фабрики и назначим ему префаб снаряда.


War factory.

Поведение в игре


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

using UnityEngine;

public abstract class GameBehavior : MonoBehaviour {

	public virtual bool GameUpdate () => true;
}

Теперь выполним рефакторинг EnemyCollection, превратив его в GameBehaviorCollection.

public class GameBehaviorCollection {

	List<GameBehavior> behaviors = new List<GameBehavior>();

	public void Add (GameBehavior behavior) {
		behaviors.Add(behavior);
	}

	public void GameUpdate () {
		for (int i = 0; i < behaviors.Count; i++) {
			if (!behaviors[i].GameUpdate()) {
				int lastIndex = behaviors.Count - 1;
				behaviors[i] = behaviors[lastIndex];
				behaviors.RemoveAt(lastIndex);
				i -= 1;
			}
		}
	}
}

Сделаем так, чтобы WarEntity расширял GameBehavior, а не MonoBehavior.

public abstract class WarEntity : GameBehavior { … }

Сделаем то же самое и для Enemy, на этот раз переопределив метод GameUpdate.

public class Enemy : GameBehavior {

	…

	public override bool GameUpdate () { … }
	
	…
}

С этого момента Game должен будет отслеживать две коллекции, одну для врагов, другую для не-врагов. Не-враги должны обновляться после всего остального.

	GameBehaviorCollection enemies = new GameBehaviorCollection();
	GameBehaviorCollection nonEnemies = new GameBehaviorCollection();

	…

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

Последний шаг по реализации обновления снарядов — это добавление их в коллекцию не-врагов. Давайте сделаем это при помощи фукнции Game, которая будет статическим фасадом для war factory, чтобы снаряды могли создаваться вызовом Game.SpawnShell(). Чтобы это сработало, Game должен иметь ссылку на war factory и отслеживать собственный экземпляр.

	[SerializeField]
	WarFactory warFactory = default;

	…

	static Game instance;

	public static Shell SpawnShell () {
		Shell shell = instance.warFactory.Shell€;
		instance.nonEnemies.Add(shell);
		return shell;
	}

	void OnEnable () {
		instance = this;
	}


Game с war factory.

Статический фасад - это хорошее решение?
Оно удобно для простых вещей, которые могут создаваться где угодно потенциально большим количеством элементов, то есть подходит для снарядов.

Стреляем снарядом


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

	Vector3 launchPoint, targetPoint, launchVelocity;
	
	public void Initialize (
		Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity
	) {
		this.launchPoint = launchPoint;
		this.targetPoint = targetPoint;
		this.launchVelocity = launchVelocity;
	}

Теперь мы можем создать снаряд в MortarTower.Launch и отправлять его в путь.

		mortar.localRotation =
			Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y));

		Game.SpawnShell().Initialize(
			launchPoint, targetPoint,
			new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y)
		);

Движение снаряда


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

	float age;

	…

	public override bool GameUpdate () {
		age += Time.deltaTime;
		Vector3 p = launchPoint + launchVelocity * age;
		p.y -= 0.5f * 9.81f * age * age;
		transform.localPosition = p;
		return true;
	}


Стрельба снарядами.

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

	public override bool GameUpdate () {
		…
		
		Vector3 d = launchVelocity;
		d.y -= 9.81f * age;
		transform.localRotation = Quaternion.LookRotation(d);
		return true;
	}


Снаряды поворачиваются.

Подчищаем игру


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

	public void Launch (TargetPoint target) {
		…
		
		Game.SpawnShell().Initialize(
			launchPoint, targetPoint,
			new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y)
		);

	}

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

	public override bool GameUpdate () {
		age += Time.deltaTime;
		Vector3 p = launchPoint + launchVelocity * age;
		p.y -= 0.5f * 9.81f * age * age;

		if (p.y <= 0f) {
			OriginFactory.Reclaim(this);
			return false;
		}
		
		transform.localPosition = p;
		…
	}

Детонация


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

	[SerializeField, Range(0.5f, 3f)]
	float shellBlastRadius = 1f;

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


Радиус взрыва и 1.5 урон 15 снаряда.

Эта конфигурация важна только во время взрыва, поэтому её нужно добавить в Shell и его метод Initialize.

	float age, blastRadius, damage;

	public void Initialize (
		Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity,
		float blastRadius, float damage
	) {
		…
		this.blastRadius = blastRadius;
		this.damage = damage;
	}

MortarTower должен только передавать данные снаряду после его создания.

		Game.SpawnShell().Initialize(
			launchPoint, targetPoint,
			new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y),
			shellBlastRadius, shellDamage
		);

Чтобы стрелять по врагам в пределах дальности, снаряд должен захватывать цели. У нас уже есть код для этого, но он находится в Tower. Так как он полезен для всего, чему требуется цель, скопируем его функциональность в TargetPoint и сделаем её статически доступной. Добавим метод для заполнения буфера, свойство для получения буферизированного количества и метод для получения буферизованной цели.

	const int enemyLayerMask = 1 << 9;

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

	public static int BufferedCount { get; private set; }

	public static bool FillBuffer (Vector3 position, float range) {
		Vector3 top = position;
		top.y += 3f;
		BufferedCount = Physics.OverlapCapsuleNonAlloc(
			position, top, range, buffer, enemyLayerMask
		);
		return BufferedCount > 0;
	}

	public static TargetPoint GetBuffered (int index) {
		var target = buffer[index].GetComponent<TargetPoint>();
		Debug.Assert(target != null, "Targeted non-enemy!", buffer[0]);
		return target;
	}

Теперь мы можем получать все цели в пределах дальности вплоть до максимального размера буфера и наносить им урон при детонации Shell.

		if (p.y <= 0f) {
			TargetPoint.FillBuffer(targetPoint, blastRadius);
			for (int i = 0; i < TargetPoint.BufferedCount; i++) {
				TargetPoint.GetBuffered(i).Enemy€.ApplyDamage(damage);
			}
			OriginFactory.Reclaim(this);
			return false;
		}


Детонирование снарядов.

Также можно добавить к TargetPoint статическое свойство, чтобы получать случайную цель из буфера.

	public static TargetPoint RandomBuffered =>
		GetBuffered(Random.Range(0, BufferedCount));

Это позволит нам упростить Tower, потому что теперь для поиска случайной цели можно использовать TargetPoint.


	protected bool AcquireTarget (out TargetPoint target) {
		if (TargetPoint.FillBuffer(transform.localPosition, targetingRange)) {
			target = TargetPoint.RandomBuffered;
			return true;
		}
		target = null;
		return false;
	}

Взрывы


Всё работает, но выглядит пока не очень правдоподобно. Можно улучшить картину, добавив визуализацию взрыва при детонации снаряда. Это не только будет выглядеть интереснее, но и даст игроку полезную обратную связь. Для этого мы создадим префаб взрыва наподобие лазерного луча. Только он будет более прозрачной сферой яркого цвета. Добавим ему новый компонент сущности Explosion с настраиваемой длительностью. Достаточно будет половины секунды. Добавим ей метод Initialize, задающий позицию и радиус взрыва. При задании масштаба нужно удвоить радиус, потому что радиус меша сферы равен 0.5. Также это подходящее место для нанесения урона всем врагам в пределах дальности, поэтому добавим ещё и параметр урона. Кроме того, ему нужен метод GameUpdate, проверяющий, закончилось ли время.

using UnityEngine;

public class Explosion : WarEntity {

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

	float age;

	public void Initialize (Vector3 position, float blastRadius, float damage) {
		TargetPoint.FillBuffer(position, blastRadius);
		for (int i = 0; i < TargetPoint.BufferedCount; i++) {
			TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage);
		}
		transform.localPosition = position;
		transform.localScale = Vector3.one * (2f * blastRadius);
	}

	public override bool GameUpdate () {
		age += Time.deltaTime;
		if (age >= duration) {
			OriginFactory.Reclaim(this);
			return false;
		}
		return true;
	}
}

Добавим взрыв в WarFactory.

	[SerializeField]
	Explosion explosionPrefab = default;

	[SerializeField]
	Shell shellPrefab = default;

	public Explosion Explosion€ => Get(explosionPrefab);

	public Shell Shell => Get(shellPrefab);


War factory со взрывом.

Также добавим в Game фасадный метод.

	public static Explosion SpawnExplosion () {
		Explosion explosion = instance.warFactory.Explosion€;
		instance.nonEnemies.Add(explosion);
		return explosion;
	}

Теперь Shell может порождать и инициализировать взрыв при достижении цели. Наносить урон будет сам взрыв.

		if (p.y <= 0f) {
			Game.SpawnExplosion().Initialize(targetPoint, blastRadius, damage);
			OriginFactory.Reclaim(this);
			return false;
		}


Взрывы снарядов.

Более плавные взрывы


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

	[SerializeField]
	AnimationCurve opacityCurve = default;

	[SerializeField]
	AnimationCurve scaleCurve = default;

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


Кривые взрыва.

Для задания цвета материала мы воспользуемся блоком свойства материала. где чёрный цвет — это переменная opacity. Масштаб теперь задаётся в GameUpdate, но нам нужно отслеживать при помощи поля радиус. В Initialize можно использовать удвоение масштаба. Значения кривых находятся при помощи вызова для них Evaluate с аргументом, вычисляемым как текущий срок жизни взрыва, разделённый на длительность взрыва.

	static int colorPropertyID = Shader.PropertyToID("_Color");

	static MaterialPropertyBlock propertyBlock;
	
	…
	
	float scale;

	MeshRenderer meshRenderer;

	void Awake () {
		meshRenderer = GetComponent<MeshRenderer>();
		Debug.Assert(meshRenderer != null, "Explosion without renderer!");
	}

	public void Initialize (Vector3 position, float blastRadius, float damage) {
		…
		transform.localPosition = position;
		scale = 2f * blastRadius;
	}
	
	public override bool GameUpdate () {
		…
		
		if (propertyBlock == null) {
			propertyBlock = new MaterialPropertyBlock();
		}
		float t = age / duration;
		Color c = Color.clear;
		c.a = opacityCurve.Evaluate(t);
		propertyBlock.SetColor(colorPropertyID, c);
		meshRenderer.SetPropertyBlock(propertyBlock);
		transform.localScale = Vector3.one * (scale * scaleCurve.Evaluate(t));
		return true;
	}


Анимированные взрывы.

Снаряды-трассеры


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

Существуют разные способы создания следов, но вы воспользуемся очень простым. Мы переделаем взрывы так, чтобы Shell создавал маленький взрыв в каждом кадре. Эти взрывы не будут наносить никакого урона, поэтому захват целей будет пустой тратой ресурсов. Добавим в Explosion поддержку такого использования, сделав так, чтобы урон наносился, если он больше нуля, а затем сделаем параметр урона в Initialize необязательным.

	public void Initialize (
		Vector3 position, float blastRadius, float damage = 0f
	) {
		if (damage > 0f) {
			TargetPoint.FillBuffer(position, blastRadius);
			for (int i = 0; i < TargetPoint.BufferedCount; i++) {
				TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage);
			}
		}
		transform.localPosition = position;
		radius = 2f * blastRadius;
	}

Будем создавать взрыв в конце Shell.GameUpdate с небольшим радиусом, например, 0.1, чтобы превратить их в снаряды-трассеры. Нужно учесть, что при таком подходе взрывы будут создаваться покадрово, то есть они зависят от частоты кадров, но для такого простого эффекта это допустимо.

	public override bool GameUpdate () {
		…
		
		Game.SpawnExplosion().Initialize(p, 0.1f);
		return true;
	}

image

Снаряды-трассеры.

Репозиторий туториала
Статья в формате PDF
Источник: https://habr.com/ru/post/461605/


Интересные статьи

Интересные статьи

Всем привет! Занимаюсь разработкой игры: ее дизайн полностью сделан из символов, которые можно набрать на клавиатуре. Используются символы из ASCII таблицы. Как бы странн...
Неделю назад в Москве прошло второе международное состязание киборгов — Cybathlon 2020. Первое состоялось в 2016 году в Цюрихе. Помните, мы рассказывали, как это было? В этот раз Росс...
Вам приходилось сталкиваться с ситуацией, когда сайт или портал Битрикс24 недоступен, потому что на диске неожиданно закончилось место? Да, последний бэкап съел все место на диске в самый неподходящий...
В 80-х и 90-х не каждому ребёнку родители покупали Game Boy. Я был одним из таких детей, и оставался единственным на игровой площадке, у кого не было GB. Вместо консоли у меня был графически...
В контексте событий про Open Distro, открытие исходников X-Pack, а также статьи «The Cloud and Open Source Powder Keg» — перевод поста Шейя Бэнона (основатель и CEO Elastic). ...