Создание игры Tower Defense в Unity: враги

Автор оригинала: Jasper Flick
  • Перевод
[Первая часть: тайлы и поиск пути]

  • Размещение точек создания врагов.
  • Появление врагов и их движение по полю.
  • Создание плавного движения с постоянной скоростью.
  • Изменение размера, скорости и размещения врагов.

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

Данный туториал выполнен в Unity 2018.3.0f2.


Враги на пути к конечной точке.

Точки создания (спауна) врагов


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

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


Точка спауна — это ещё один тип содержимого тайлов, поэтому добавим запись для него в GameTileContentType.

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

А затем создадим префаб, чтобы визуализировать его. Нам вполне подойдёт дубликат префаба начальной точки, просто изменим его тип содержимого и дадим ему другой материал. Я сделал его оранжевым.


Конфигурация точек спауна.

Добавим поддержку точек спауна в фабрику содержимого и дадим ему ссылку на префаб.

	[SerializeField]
	GameTileContent spawnPointPrefab = default;

	…

	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			case GameTileContentType.Destination: return Get(destinationPrefab);
			case GameTileContentType.Empty: return Get(emptyPrefab);
			case GameTileContentType.Wall: return Get(wallPrefab);
			case GameTileContentType.SpawnPoint: return Get(spawnPointPrefab);
		}
		Debug.Assert(false, "Unsupported type: " + type);
		return null;
	}


Фабрика с поддержкой точек спауна.

Включение и отключение точек спауна


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

	public void ToggleSpawnPoint (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.SpawnPoint) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint);
		}
	}

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

	List<GameTile> spawnPoints = new List<GameTile>();

	…

	public void ToggleSpawnPoint (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.SpawnPoint) {
			if (spawnPoints.Count > 1) {
				spawnPoints.Remove(tile);
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
			}
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint);
			spawnPoints.Add(tile);
		}
	}

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

	public void Initialize (
		Vector2Int size, GameTileContentFactory contentFactory
	) {
		…

		ToggleDestination(tiles[tiles.Length / 2]);
		ToggleSpawnPoint(tiles[0]);
	}

Мы сделаем так, чтобы теперь альтернативное касание переключало состояние точек спауна, но при зажатом левом Shift (нажатие клавиши проверяется методом Input.GetKey) будет переключаться состояние конечной точки

	void HandleAlternativeTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			if (Input.GetKey(KeyCode.LeftShift)) {
				board.ToggleDestination(tile);
			}
			else {
				board.ToggleSpawnPoint(tile);
			}
		}
	}


Поле с точками спауна.

Получение доступа к точкам спауна


Поле занимается всеми своими тайлами, но враги — это не его ответственность. Мы сделаем так, чтобы можно было получать доступ к его точкам спауна через общий метод GetSpawnPoint с параметром-индексом.

	public GameTile GetSpawnPoint (int index) {
		return spawnPoints[index];
	}

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

	public int SpawnPointCount => spawnPoints.Count;

Спаунинг врагов


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

Фабрики


Мы создадим для врагов фабрику, которая будет помещать всё создаваемое ею на собственную сцену. Этот функционал является общим с той фабрикой, которая у нас уже есть, поэтому давайте поместим код для него в общий базовый класс GameObjectFactory. Нам будет достаточно одного метода CreateGameObjectInstance с общим параметром префаба, который создаёт и возвращает экземпляр, а также занимается управлением всей сценой. Сделаем метод protected, то есть он будет доступен только классу и всем типам, которые от него наследуют. Это всё, что делает класс, он не предназначен для использования в качестве полнофункциональной фабрики. Поэтому пометим его как abstract, что не позволит создавать экземпляры его объектов.

using UnityEngine;
using UnityEngine.SceneManagement;

public abstract class GameObjectFactory : ScriptableObject {

	Scene scene;

	protected T CreateGameObjectInstance<T> (T prefab) where T : MonoBehaviour {
		if (!scene.isLoaded) {
			if (Application.isEditor) {
				scene = SceneManager.GetSceneByName(name);
				if (!scene.isLoaded) {
					scene = SceneManager.CreateScene(name);
				}
			}
			else {
				scene = SceneManager.CreateScene(name);
			}
		}
		T instance = Instantiate(prefab);
		SceneManager.MoveGameObjectToScene(instance.gameObject, scene);
		return instance;
	}
}

Изменим GameTileContentFactory так, чтобы он наследовал этот тип фабрики и использовал CreateGameObjectInstance в своём методе Get, а затем уберём из него код управления сценой.

using UnityEngine;

[CreateAssetMenu]
public class GameTileContentFactory : GameObjectFactory {

	…

	//Scene contentScene;

	…

	GameTileContent Get (GameTileContent prefab) {
		GameTileContent instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		//MoveToFactoryScene(instance.gameObject);
		return instance;
	}

	//void MoveToFactoryScene (GameObject o) {
	//	…
        //}
}

После этого создадим новый тип EnemyFactory, который создаёт экземпляр одного префаба Enemy с помощью метода Get вместе с сопровождающим методом Reclaim.

using UnityEngine;

[CreateAssetMenu]
public class EnemyFactory : GameObjectFactory {
	
	[SerializeField]
	Enemy prefab = default;

	public Enemy Get () {
		Enemy instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		return instance;
	}

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

Новый тип Enemy изначально должен только отслеживать свою исходную фабрику.

using UnityEngine;

public class Enemy : MonoBehaviour {

	EnemyFactory originFactory;

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

Префаб


Врагам нужна визуализация, которая может быть любой — робот, паук, призрак, что-то более простое, например, куб, который мы и используем. Но в общем случае враг имеет 3D-модель любой сложности. Чтобы обеспечить её удобную поддержку, мы воспользуемся для иерархии префаба врага корневым объектом, к которому прикреплён только компонент Enemy.


Корень префаба.

Создадим этому объекту единственный дочерний элемент, который будет корнем модели. Он должен иметь единичные значения Transform.


Корень модели.

Задача этого корня модели — расположить 3D-модель относительно локальной точки начала координат врага, чтобы он считал её опорной точкой, над которой враг стоит или висит. В нашем случае модель будет стандартным кубом половинного размера, которому я придам тёмно-синий цвет. Сделаем его дочерним элементом корня модели и присвоим позиции по Y значение 0.25, чтобы он стоял на земле.


Модель куба.

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


Иерархия префаба врага.

Создадим фабрику врагов и назначим ей префаб.


Ассет фабрики.

Размещение врагов на поле


Чтобы поместить врагов на поле, Game должен получит ссылку на фабрику врагов. Так как нам нужно много врагов, добавим опцию конфигурации для настройки скорости спаунинга, выражаемую в количестве врагов за секунду. Приемлемым кажется интервал 0.1–10 со значением 1 по умолчанию.

	[SerializeField]
	EnemyFactory enemyFactory = default;

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


Game с фабрикой врагов и скоростью спаунинга 4.

Progress спаунинга будем отслеживать в Update, увеличивая его на скорость, умноженную на дельту времени. Если величина prggress превышает 1, то выполняем его декремент и спауним врага с помощью нового метода SpawnEnemy. Продолжаем это делать, пока progress превышает 1 на случай, если скорость слишком высока и время кадра оказалось очень длинным, чтобы одновременно не создалось несколько врагов.

	float spawnProgress;

	…

	void Update () {
		…

		spawnProgress += spawnSpeed * Time.deltaTime;
		while (spawnProgress >= 1f) {
			spawnProgress -= 1f;
			SpawnEnemy();
		}
	}

Разве не нужно обновлять progress в FixedUpdate?
Да, это возможно, но для игры жанра tower defense такие точные тайминги не нужны. Мы просто будем обновлять состояние игры каждый кадр и сделаем так, чтобы она работала достаточно хорошо при любой дельте времени.

Пусть SpawnEnemy получит случайную точку спауна с поля и создаст в этом тайле врага. Мы дадим Enemy метод SpawnOn, чтобы он правильно себя спозицинировал.

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

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

	public void SpawnOn (GameTile tile) {
		transform.localPosition = tile.transform.localPosition;
	}


Враги появляются в точках спауна.

Перемещение врагов


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

Коллекция врагов


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

	public bool GameUpdate () {
		transform.localPosition += Vector3.forward * Time.deltaTime;
		return true;
	}

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

using System.Collections.Generic;

[System.Serializable]
public class EnemyCollection {

	List<Enemy> enemies = new List<Enemy>();

	public void Add (Enemy enemy) {
		enemies.Add(enemy);
	}

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

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

	EnemyCollection enemies = new EnemyCollection();

	…

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

	…

	void SpawnEnemy () {
		…
		enemies.Add(enemy);
	}


Враги движутся вперёд.

Движение по пути


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

	public GameTile NextTileOnPath => nextOnPath;

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

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

	GameTile tileFrom, tileTo;
	Vector3 positionFrom, positionTo;
	float progress;

Инициализируем эти поля в SpawnOn. Первая точка — это тайл, из которого движется враг, а конечная точка — следующий тайл на пути. Это предполагает, что существует следующий тайл, если только враг не был создан в конечной точке, что должно быть невозможным. Тогда мы кэшируем позиции тайлов и обнулим progress. Позицию врага нам задавать здесь не нужно, потому что его метод GameUpdate вызывается в том же кадре.

	public void SpawnOn (GameTile tile) {
		//transform.localPosition = tile.transform.localPosition;
		Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this);
		tileFrom = tile;
		tileTo = tile.NextTileOnPath;
		positionFrom = tileFrom.transform.localPosition;
		positionTo = tileTo.transform.localPosition;
		progress = 0f;
	}

Инкремент progress будем выполнять в GameUpdate. Прибавим неизменную дельту времени, чтобы враги двигались со скоростью один тайл в секунду. Когда движение (progress) завершено, смещаем данные так, чтобы To становилось значение From, а новым To — следующий тайл на пути. Затем выполняем декремент progress. Когда данные становятся актуальными, интерполируем позицию врага между From и To. Так как интерполятором является progress, его значение обязательно находится в интервале от 0 и 1, моэтому мы можем использовать sVector3.LerpUnclamped.

	public bool GameUpdate () {
		progress += Time.deltaTime;
		while (progress >= 1f) {
			tileFrom = tileTo;
			tileTo = tileTo.NextTileOnPath;
			positionFrom = positionTo;
			positionTo = tileTo.transform.localPosition;
			progress -= 1f;
		}
		transform.localPosition =
			Vector3.LerpUnclamped(positionFrom, positionTo, progress);
		return true;
	}

Это заставляет врагов следовать по пути, но не будет действовать при достижении конечной точки. Поэтому прежде чем изменять позиции From и To, нужно сравнивать следующий тайл на пути с null. Если это так, то мы достигли конечной точки и враг закончил движение. Выполняем для него Reclaim и возвращаем false.

		while (progress >= 1f) {
			tileFrom = tileTo;
			tileTo = tileTo.NextTileOnPath;
			if (tileTo == null) {
				OriginFactory.Reclaim(this);
				return false;
			}
			positionFrom = positionTo;
			positionTo = tileTo.transform.localPosition;
			progress -= 1f;
		}



Враги следуют по кратчайшему пути.

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


Враги реагируют на изменение пути.

Движение от края к краю


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

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

	public Vector3 ExitPoint { get; private set; }

	…
	
	GameTile GrowPathTo (GameTile neighbor) {
		…
		neighbor.ExitPoint =
			(neighbor.transform.localPosition + transform.localPosition) * 0.5f;
		return
			neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
	}

Единственным особым случаем является конечная ячейка, точкой выхода которой будет её центр.

	public void BecomeDestination () {
		distance = 0;
		nextOnPath = null;
		ExitPoint = transform.localPosition;
	}

Изменим Enemy таким образом, чтобы он использовал точки выхода, а не центры тайлов.

	public bool GameUpdate () {
		progress += Time.deltaTime;
		while (progress >= 1f) {
			…
			positionTo = tileFrom.ExitPoint;
			progress -= 1f;
		}
		transform.localPosition = Vector3.Lerp(positionFrom, positionTo, progress);
		return true;
	}
	
	public void SpawnOn (GameTile tile) {
		…
		positionTo = tileFrom.ExitPoint;
		progress = 0f;
	}


Враги движутся между краями.

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


При повороте враги останавливаются.

Ориентация


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

У нас есть четыре направления: север, восток, юг и запад. Зададим для них перечисление.

public enum Direction {
	North, East, South, West
}

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

	public Direction PathDirection { get; private set; }

Добавим параметр направления к GrowTo, который задаёт свойство. Так как мы выращиваем путь с конца в начало, направление будет противоположным к тому, откуда мы выращиваем путь.

	public GameTile GrowPathNorth () => GrowPathTo(north, Direction.South);

	public GameTile GrowPathEast () => GrowPathTo(east, Direction.West);

	public GameTile GrowPathSouth () => GrowPathTo(south, Direction.North);

	public GameTile GrowPathWest () => GrowPathTo(west, Direction.East);

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

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

using UnityEngine;

public enum Direction {
	North, East, South, West
}

public static class DirectionExtensions {

	static Quaternion[] rotations = {
		Quaternion.identity,
		Quaternion.Euler(0f, 90f, 0f),
		Quaternion.Euler(0f, 180f, 0f),
		Quaternion.Euler(0f, 270f, 0f)
	};

	public static Quaternion GetRotation (this Direction direction) {
		return rotations[(int)direction];
	}
}

Что такое расширяющий метод (extension method)?
Расширяющий метод — это статический метод внутри статического класса, ведущий себя как метод экземпляра какого-то типа. Этот тип может быть классом, интерфейсом, структурой, примитивным значением или перечислением. Первый аргумент расширяющего метода должен иметь ключевое слово this. Он определяет значение типа и экземпляра, с которым будет работать метод. Такой подход обозначает, что расширяющие свойства невозможны.

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

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

	public bool GameUpdate () {
		progress += Time.deltaTime;
		while (progress >= 1f) {
			…
			transform.localRotation = tileFrom.PathDirection.GetRotation();
			progress -= 1f;
		}
		transform.localPosition =
			Vector3.LerpUnclamped(positionFrom, positionTo, progress);
		return true;
	}

	public void SpawnOn (GameTile tile) {
		…
		transform.localRotation = tileFrom.PathDirection.GetRotation();
		progress = 0f;
	}

Смена направления


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

public enum Direction {
	North, East, South, West
}

public enum DirectionChange {
	None, TurnRight, TurnLeft, TurnAround
}

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

	public static DirectionChange GetDirectionChangeTo (
		this Direction current, Direction next
	) {
		if (current == next) {
			return DirectionChange.None;
		}
		else if (current + 1 == next || current - 3 == next) {
			return DirectionChange.TurnRight;
		}
		else if (current - 1 == next || current + 3 == next) {
			return DirectionChange.TurnLeft;
		}
		return DirectionChange.TurnAround;
	}

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

	public static float GetAngle (this Direction direction) {
		return (float)direction * 90f;
	}

Теперь Enemy придётся отслеживать направление, смену направления и углы, между которыми нужно выполнять интерполяцию.

	Direction direction;
	DirectionChange directionChange;
	float directionAngleFrom, directionAngleTo;

SpawnOn становится сложнее, поэтому давайте переместим код подготовки состояния в другой метод. Мы назначим исходное состояние врага как вводное состояние, поэтому назовём его PrepareIntro. В этом состоянии враг перемещается от центра к краю своего начального тайла, поэтому смена направления не происходит. Углы From и To одинаковы.

	public void SpawnOn (GameTile tile) {
		Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this);
		tileFrom = tile;
		tileTo = tile.NextTileOnPath;
		//positionFrom = tileFrom.transform.localPosition;
		//positionTo = tileFrom.ExitPoint;
		//transform.localRotation = tileFrom.PathDirection.GetRotation();
		progress = 0f;
		PrepareIntro();
	}

	void PrepareIntro () {
		positionFrom = tileFrom.transform.localPosition;
		positionTo = tileFrom.ExitPoint;
		direction = tileFrom.PathDirection;
		directionChange = DirectionChange.None;
		directionAngleFrom = directionAngleTo = direction.GetAngle();
		transform.localRotation = direction.GetRotation();
	}

На этом этапе мы создаём нечто наподобие небольшого конечного автомата. Чтобы не усложнять GameUpdate, переместим код изменения состояния в новый метод PrepareNextState. Оставим только изменения тайлов From и To, потому что мы используем их здесь для проверки того, закончил ли враг путь.

	public bool GameUpdate () {
		progress += Time.deltaTime;
		while (progress >= 1f) {
			…
			//positionFrom = positionTo;
			//positionTo = tileFrom.ExitPoint;
			//transform.localRotation = tileFrom.PathDirection.GetRotation();
			progress -= 1f;
			PrepareNextState();
		}
		…
	}

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

	void PrepareNextState () {
		positionFrom = positionTo;
		positionTo = tileFrom.ExitPoint;
		directionChange = direction.GetDirectionChangeTo(tileFrom.PathDirection);
		direction = tileFrom.PathDirection;
		directionAngleFrom = directionAngleTo;
	}

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

	void PrepareForward () {
		transform.localRotation = direction.GetRotation();
		directionAngleTo = direction.GetAngle();
	}

В случае поворота мы не поворачиваемся мгновенно. Нам нужно интерполировать к другому углу: на 90° больше для поворота вправо, на 90° меньше для поворота влево, и на 180° больше для поворота назад. Чтобы избежать поворота не в том направлении из-за смены значений углов от 359° к 0°, угол To должен указываться относительно текущего направления. Нам не нужно волноваться, что угол станет меньше 0° или больше 360°, потому что Quaternion.Euler может справиться с этим.

	void PrepareTurnRight () {
		directionAngleTo = directionAngleFrom + 90f;
	}

	void PrepareTurnLeft () {
		directionAngleTo = directionAngleFrom - 90f;
	}

	void PrepareTurnAround () {
		directionAngleTo = directionAngleFrom + 180f;
	}

В конце PrepareNextState мы можем использовать switch для смены направления, чтобы решить, какой из четырёх методов вызывать.

	void PrepareNextState () {
		…
		switch (directionChange) {
			case DirectionChange.None: PrepareForward(); break;
			case DirectionChange.TurnRight: PrepareTurnRight(); break;
			case DirectionChange.TurnLeft: PrepareTurnLeft(); break;
			default: PrepareTurnAround(); break;
		}
	}

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

	public bool GameUpdate () {
		…
		transform.localPosition =
			Vector3.LerpUnclamped(positionFrom, positionTo, progress);
		if (directionChange != DirectionChange.None) {
			float angle = Mathf.LerpUnclamped(
				directionAngleFrom, directionAngleTo, progress
			);
			transform.localRotation = Quaternion.Euler(0f, angle, 0f);
		}
		return true;
	}


Враги поворачиваются.

Движение по кривой


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


Вращение на четверть круга для поворота вправо.

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

	[SerializeField]
	Transform model = default;


Enemy со ссылкой на модель.

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

	void PrepareForward () {
		transform.localRotation = direction.GetRotation();
		directionAngleTo = direction.GetAngle();
		model.localPosition = Vector3.zero;
	}

	void PrepareTurnRight () {
		directionAngleTo = directionAngleFrom + 90f;
		model.localPosition = new Vector3(-0.5f, 0f);
	}

	void PrepareTurnLeft () {
		directionAngleTo = directionAngleFrom - 90f;
		model.localPosition = new Vector3(0.5f, 0f);
	}

	void PrepareTurnAround () {
		directionAngleTo = directionAngleFrom + 180f;
		model.localPosition = Vector3.zero;
	}

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

	static Vector3[] halfVectors = {
		Vector3.forward * 0.5f,
		Vector3.right * 0.5f,
		Vector3.back * 0.5f,
		Vector3.left * 0.5f
	};

	…

	public static Vector3 GetHalfVector (this Direction direction) {
		return halfVectors[(int)direction];
	}

Прибавляем соответствующий вектор при повороте вправо или влево.

	void PrepareTurnRight () {
		directionAngleTo = directionAngleFrom + 90f;
		model.localPosition = new Vector3(-0.5f, 0f);
		transform.localPosition = positionFrom + direction.GetHalfVector();
	}

	void PrepareTurnLeft () {
		directionAngleTo = directionAngleFrom - 90f;
		model.localPosition = new Vector3(0.5f, 0f);
		transform.localPosition = positionFrom + direction.GetHalfVector();
	}

А при повороте назад позиция должна быть обычной начальной точкой.

	void PrepareTurnAround () {
		directionAngleTo = directionAngleFrom + 180f;
		model.localPosition = Vector3.zero;
		transform.localPosition = positionFrom;
	}

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

		neighbor.ExitPoint =
			neighbor.transform.localPosition + direction.GetHalfVector();

Теперь при смене направления мы не должны интерполировать позицию в Enemy.GameUpdate, потому что движением занимается поворот.

	public bool GameUpdate () {
		…
		if (directionChange == DirectionChange.None) {
			transform.localPosition =
				Vector3.LerpUnclamped(positionFrom, positionTo, progress);
		}
		//if (directionChange != DirectionChange.None) {
		else {
			float angle = Mathf.LerpUnclamped(
				directionAngleFrom, directionAngleTo, progress
			);
			transform.localRotation = Quaternion.Euler(0f, angle, 0f);
		}
		return true;
	}


Враги плавно огибают углы.

Постоянная скорость


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

	float progress, progressFactor;

	…

	public bool GameUpdate () {
		progress += Time.deltaTime * progressFactor;
		…
	}

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

	public bool GameUpdate () {
		progress += Time.deltaTime * progressFactor;
		while (progress >= 1f) {
			…
			//progress -= 1f;
			progress = (progress - 1f) / progressFactor;
			PrepareNextState();
			progress *= progressFactor;
		}
		…
	}

Движение вперёд не требует изменений, поэтому использует множитель 1. При повороте вправо или влево враг проходит четверть окружности с радиусом ½, поэтому покрываемое расстояние равно ¼π. progress равен единице, разделённой на эту величину. Поворот назад не должен занимать слишком много времени, поэтому удвоим progress, чтобы он занимал полсекунды. Наконец, вводное движение покрывает только половину тайла, поэтому для сохранения постоянной скорости его progress тоже нужно удвоить.

	void PrepareForward () {
		…
		progressFactor = 1f;
	}

	void PrepareTurnRight () {
		…
		progressFactor = 1f / (Mathf.PI * 0.25f);
	}

	void PrepareTurnLeft () {
		…
		progressFactor = 1f / (Mathf.PI * 0.25f);
	}

	void PrepareTurnAround () {
		…
		progressFactor = 2f;
	}

	void PrepareIntro () {
		…
		progressFactor = 2f;
	}

Почему расстояние равно 1/4*pi?
Длина окружности равна 2π, умноженному на радиус. Поворот вправо или влево покрывает всего четверть этой длины, а радиус равен ½, поэтому расстояние равно ½π × ½.

Завершающее состояние


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

	void PrepareOutro () {
		positionTo = tileFrom.transform.localPosition;
		directionChange = DirectionChange.None;
		directionAngleTo = direction.GetAngle();
		model.localPosition = Vector3.zero;
		transform.localRotation = direction.GetRotation();
		progressFactor = 2f;
	}

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

	public bool GameUpdate () {
		progress += Time.deltaTime * progressFactor;
		while (progress >= 1f) {
			//tileFrom = tileTo;
			//tileTo = tileTo.NextTileOnPath;
			if (tileTo == null) {
				OriginFactory.Reclaim(this);
				return false;
			}
			…
		}
		…
	}

В PrepareNextState мы начнём со сдвига тайлов. Затем после задания позиции From, но перед заданием позиции To будем проверять, равен ли тайл To значению null. Если да, то подготавливаем завершающее состояние и пропускаем остальную часть метода.

	void PrepareNextState () {
		tileFrom = tileTo;
		tileTo = tileTo.NextTileOnPath;
		positionFrom = positionTo;
		if (tileTo == null) {
			PrepareOutro();
			return;
		}
		positionTo = tileFrom.ExitPoint;
		…
	}


Враги с постоянной скоростью и завершающим состоянием.

Вариативность врагов


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

Интервал значений Float


Мы будем изменять параметры врагов, случайным образом выбирая их характеристики из интервала значений. Здесь будет полезна структура FloatRange, которую мы создали в статье Object Management, Configuring Shapes, поэтому давайте её скопируем. Единственными изменениями стали добавление конструктора с одним параметром и открытие доступа к минимуму и максимуму с помощью readonly-свойств, чтобы интервал был неизменяемым.

using UnityEngine;

[System.Serializable]
public struct FloatRange {

	[SerializeField]
	float min, max;

	public float Min => min;

	public float Max => max;
	
	public float RandomValueInRange {
		get {
			return Random.Range(min, max);
		}
	}
	
	public FloatRange(float value) {
		min = max = value;
	}

	public FloatRange (float min, float max) {
		this.min = min;
		this.max = max < min ? min : max;
	}
}

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

using UnityEngine;

public class FloatRangeSliderAttribute : PropertyAttribute {

	public float Min { get; private set; }

	public float Max { get; private set; }

	public FloatRangeSliderAttribute (float min, float max) {
		Min = min;
		Max = max < min ? min : max;
	}
}

Нам нужна только визуализация ползунка, поэтому скопируем FloatRangeSliderDrawer в папку Editor.

using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(FloatRangeSliderAttribute))]
public class FloatRangeSliderDrawer : PropertyDrawer {

	public override void OnGUI (
		Rect position, SerializedProperty property, GUIContent label
	) {
		int originalIndentLevel = EditorGUI.indentLevel;
		EditorGUI.BeginProperty(position, label, property);

		position = EditorGUI.PrefixLabel(
			position, GUIUtility.GetControlID(FocusType.Passive), label
		);
		EditorGUI.indentLevel = 0;
		SerializedProperty minProperty = property.FindPropertyRelative("min");
		SerializedProperty maxProperty = property.FindPropertyRelative("max");
		float minValue = minProperty.floatValue;
		float maxValue = maxProperty.floatValue;
		float fieldWidth = position.width / 4f - 4f;
		float sliderWidth = position.width / 2f;
		position.width = fieldWidth;
		minValue = EditorGUI.FloatField(position, minValue);
		position.x += fieldWidth + 4f;
		position.width = sliderWidth;
		FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute;
		EditorGUI.MinMaxSlider(
			position, ref minValue, ref maxValue, limit.Min, limit.Max
		);
		position.x += sliderWidth + 4f;
		position.width = fieldWidth;
		maxValue = EditorGUI.FloatField(position, maxValue);
		if (minValue < limit.Min) {
			minValue = limit.Min;
		}
		if (maxValue < minValue) {
			maxValue = minValue;
		}
		else if (maxValue > limit.Max) {
			maxValue = limit.Max;
		}
		minProperty.floatValue = minValue;
		maxProperty.floatValue = maxValue;

		EditorGUI.EndProperty();
		EditorGUI.indentLevel = originalIndentLevel;
	}
}

Масштаб модели


Начнём мы с изменения масштаба врага. Добавим в EnemyFactory опцию настройки масштаба. Интервал масштабов не должен быть слишком большим, но достаточным для создания миниатюрных и гигантски разновидностей врагов. Что-нибудь в пределах 0.5–2 со стандартным значением 1. Будем выбирать случайный масштаб в этом интервале в Get и передавать его врагу через новый метод Initialize.

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

	public Enemy Get () {
		Enemy instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		instance.Initialize(scale.RandomValueInRange);
		return instance;
	}

Метод Enemy.Initialize просто задаёт одинаковый по всем измерениям масштаб его модели.

	public void Initialize (float scale) {
		model.localScale = new Vector3(scale, scale, scale);
	}

inspector

scene

Интервал масштабов от 0.5 до 1.5.

Смещение пути


Чтобы ещё сильнее разрушить однородность потока врагов, мы можем изменить их относительную позицию внутри тайлов. Они движутся вперёд, поэтому смещение в этом направлении всего лишь изменяет тайминг их движения, что не очень заметно. Поэтому мы будем смещать их вбок, в сторону от идеального пути, проходящего через центры тайлов. Добавим в EnemyFactory интервал смещений пути и будем передавать случайное смещение методу Initialize. Смещение может быть отрицательным или положительным, но никогда не больше ½, потому что это сдвинуло бы врага на соседний тайл. Кроме того, мы не хотим, чтобы враги выходили за пределы тайлов, по которым идут, поэтому на самом деле интервал будет меньше, например, 0.4, однако истинные пределы зависят от размера врага.

	[SerializeField, FloatRangeSlider(-0.4f, 0.4f)]
	FloatRange pathOffset = new FloatRange(0f);

	public Enemy Get () {
		Enemy instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		instance.Initialize(
			scale.RandomValueInRange, pathOffset.RandomValueInRange
		);
		return instance;
	}

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

	float pathOffset;

	…

	public void Initialize (float scale, float pathOffset) {
		model.localScale = new Vector3(scale, scale, scale);
		this.pathOffset = pathOffset;
	}

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

	void PrepareForward () {
		transform.localRotation = direction.GetRotation();
		directionAngleTo = direction.GetAngle();
		model.localPosition = new Vector3(pathOffset, 0f);
		progressFactor = 1f;
	}

	void PrepareTurnRight () {
		directionAngleTo = directionAngleFrom + 90f;
		model.localPosition = new Vector3(pathOffset - 0.5f, 0f);
		transform.localPosition = positionFrom + direction.GetHalfVector();
		progressFactor = 1f / (Mathf.PI * 0.25f);
	}

	void PrepareTurnLeft () {
		directionAngleTo = directionAngleFrom - 90f;
		model.localPosition = new Vector3(pathOffset + 0.5f, 0f);
		transform.localPosition = positionFrom + direction.GetHalfVector();
		progressFactor = 1f / (Mathf.PI * 0.25f);
	}

	void PrepareTurnAround () {
		directionAngleTo = directionAngleFrom + 180f;
		model.localPosition = new Vector3(pathOffset, 0f);
		transform.localPosition = positionFrom;
		progressFactor = 2f;
	}

	void PrepareIntro () {
		…
		model.localPosition = new Vector3(pathOffset, 0f);
		transform.localRotation = direction.GetRotation();
		progressFactor = 2f;
	}

	void PrepareOutro () {
		…
		model.localPosition = new Vector3(pathOffset, 0f);
		transform.localRotation = direction.GetRotation();
		progressFactor = 2f;
	}

Так как смещение пути при повороте изменяет радиус, нам необходимо изменить процесс вычисления множителя progress. Смещение пути должно вычитаться из ½, чтобы получить радиус поворота вправо, и прибавляться в случае поворота влево.

	void PrepareTurnRight () {
		…
		progressFactor = 1f / (Mathf.PI * 0.5f * (0.5f - pathOffset));
	}

	void PrepareTurnLeft () {
		…
		progressFactor = 1f / (Mathf.PI * 0.5f * (0.5f + pathOffset));
	}

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

	void PrepareTurnAround () {
		directionAngleTo = directionAngleFrom + (pathOffset < 0f ? 180f : -180f);
		model.localPosition = new Vector3(pathOffset, 0f);
		transform.localPosition = positionFrom;
		progressFactor =
			1f / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f));
	}

inspector


Смещение пути в интервале −0.25–0.25.

Заметьте, что теперь враги никогда не меняют своё относительное смещение пути, даже при повороте. Поэтому общая длина пути у каждого врага своя.

Чтобы враги не выходили на соседние тайлы, надо также учитывать их максимальный возможный масштаб. Я просто ограничил размер максимальным значением 1, поэтому максимальное допустимое смещение для куба равно 0.25. Если бы максимальный размер был равен 1.5, то максимум смещения надо было снизить до 0.125.

Скорость


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

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

	[SerializeField, FloatRangeSlider(-0.4f, 0.4f)]
	FloatRange pathOffset = new FloatRange(0f);

	public Enemy Get () {
		Enemy instance = CreateGameObjectInstance(prefab);
		instance.OriginFactory = this;
		instance.Initialize(
			scale.RandomValueInRange,
			speed.RandomValueInRange,
			pathOffset.RandomValueInRange
		);
		return instance;
	}

Теперь Enemy должен отслеживать и скорость.

	float speed;

	…

	public void Initialize (float scale, float speed, float pathOffset) {
		model.localScale = new Vector3(scale, scale, scale);
		this.speed = speed;
		this.pathOffset = pathOffset;
	}

Когда мы не задавали скорость явно, то просто всегда использовали значение 1. Теперь нам просто создать зависимость множителя progress от скорости.

	void PrepareForward () {
		…
		progressFactor = speed;
	}

	void PrepareTurnRight () {
		…
		progressFactor = speed / (Mathf.PI * 0.5f * (0.5f - pathOffset));
	}

	void PrepareTurnLeft () {
		…
		progressFactor = speed / (Mathf.PI * 0.5f * (0.5f + pathOffset));
	}

	void PrepareTurnAround () {
		…
		progressFactor =
			speed / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f));
	}

	void PrepareIntro () {
		…
		progressFactor = 2f * speed;
	}

	void PrepareOutro () {
		…
		progressFactor = 2f * speed;
	}



Скорость в интервале 0.75–1.25.

Итак, мы получили красивый поток врагов, движущихся к конечной точке. В следующем туториале мы научимся с ними бороться. Хотите знать, когда он выйдет? Следите за моей страницей на Patreon!

репозиторий

Статья в PDF
  • +26
  • 4,9k
  • 1
Поддержать автора
Поделиться публикацией

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 1

    +2
    Хороший материал, накину немножко:

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

    234
    123
    X12

    X- место спавна.
    1- варианты шага 1
    2- варианты шага 2
    и т.д.

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

    Самое читаемое