Как создать простую Tower Defense игру на Unity3D, часть вторая

в 16:23, , рубрики: .net, game development, tower defense, unity3d, гайд, урок, метки: , , , ,

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

Часть первая

Как создать простую Tower Defense игру на Unity3D, часть вторая
Неудачный тест физики

В этой части мы:
— оптимизируем код из предыдущей статьи;
— создадим объект «база» и научим её чиниться время от времени;
— добавим пушкам патроны и перезарядку;
— избавимся от «неудобной» переменной gv;

А в конце статьи вас ожидает маленький бонус :)

Всем заинтересовавшимся — добро пожаловать под долгожданный кат!

Оптимизация, багфиксы, перестановка на сцене и всё такое

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

Начнём же мы с AI скрипта пушки, изменения в котором коснулись способа расчёта расстояния, появилась обойма с патронами, и перезарядка, длящаяся указанное время:

PlasmaTurretAI.cs

using UnityEngine;

public class PlasmaTurretAI : MonoBehaviour
{
	public GameObject curTarget;
	public float towerPrice = 100.0f;
	public float attackMaximumDistance = 50.0f; //дистанция атаки
	public float attackMinimumDistance = 5.0f;
	public float attackDamage = 10.0f; //урон
	public float reloadTimer = 2.5f; //задержка между выстрелами, изменяемое значение
	public float reloadCooldown = 2.5f; //задержка между выстрелами, константа
	public float rotationSpeed = 1.5f; //множитель скорости вращения башни
	public int FiringOrder = 1; //очередность стрельбы для стволов (у нас же их 2)
	public int upgradeLevel = 0;
	public int ammoAmount = 64;
	public int ammoAmountConst = 64;
	public float ammoReloadTimer = 5.0f;
	public float ammoReloadConst = 5.0f;
	public LayerMask turretLayerMask; //в самой Unity3D создайте новый слой для мобов по аналогии с тегами и выберите его тут. Я назвал его Monster. Не забудьте выбрать его на префабе моба.

	public Transform turretHead;

	//используем этот метод для инициализации
	private void Start()
	{
		turretHead = transform.Find("pushka"); //находим башню в иерархии частей модели
	}

	//а этот метод вызывается каждый фрейм
	private void Update()
	{
		if (curTarget != null) //если переменная текущей цели не пустая
		{
			float squaredDistance = (turretHead.position - curTarget.transform.position).sqrMagnitude; //меряем дистанцию до нее
			if (Mathf.Pow(attackMinimumDistance, 2) < squaredDistance && squaredDistance < Mathf.Pow(attackMaximumDistance, 2)) //если дистанция больше мертвой зоны и меньше дистанции поражения пушки
			{
				turretHead.rotation = Quaternion.Lerp(turretHead.rotation, Quaternion.LookRotation(curTarget.transform.position - turretHead.position), rotationSpeed * Time.deltaTime); //вращаем башню в сторону цели
				if (reloadTimer > 0) reloadTimer -= Time.deltaTime; //если таймер перезарядки больше нуля - отнимаем его
				if (reloadTimer <= 0)
				{
					if (ammoAmount > 0) //пока есть порох в пороховницах
					{
						MobHP mhp = curTarget.GetComponent<MobHP>();
						switch (FiringOrder) //смотрим, из какого ствола стрелять
						{
							case 1:
								if (mhp != null) mhp.ChangeHP(-attackDamage); //наносим урон цели
								FiringOrder++; //переключаем ствол
								ammoAmount--; //минус патрон
								break;
							case 2:
								if (mhp != null) mhp.ChangeHP(-attackDamage);
								FiringOrder = 1;
								ammoAmount--;
								break;
						}
						reloadTimer = reloadCooldown; //возвращаем переменной таймера перезарядки её первоначальное значение из "константы"
					}
					else
					{
						if (ammoReloadTimer > 0) ammoReloadTimer -= Time.deltaTime;
						if (ammoReloadTimer <= 0)
						{
							ammoAmount = ammoAmountConst;
							ammoReloadTimer = ammoReloadConst;
						}
					}
				}
				if (squaredDistance < Mathf.Pow(attackMinimumDistance, 2)) curTarget = null;//сбрасываем с прицела текущую цель, если она вне радиуса атаки
			}
		}
		else
		{
			curTarget = SortTargets(); //сортируем цели и получаем новую
		}
	}

	//Модифицированный алгоритм поиска ближайшей цели
	private GameObject SortTargets()
	{
		float closestMobSquaredDistance = 0; //переменная для хранения квадрата расстояния ближайшего моба
		GameObject nearestmob = null; //инициализация переменной ближайшего моба
		Collider[] mobColliders = Physics.OverlapSphere(transform.position, attackMaximumDistance, turretLayerMask.value); //находим коллайдеры всех мобов в радиусе максимальной дальности атаки и создаём массив для сортировки

		foreach (var mobCollider in mobColliders) //для каждого коллайдера в массиве
		{
			float distance = (mobCollider.transform.position - turretHead.position).sqrMagnitude;
			//если дистанция до моба меньше, чем closestMobDistance или равна нулю
			if (distance < closestMobSquaredDistance && (distance > Mathf.Pow(attackMinimumDistance, 2)) || closestMobSquaredDistance == 0)
			{
				closestMobSquaredDistance = distance; //записываем её в переменную
				nearestmob = mobCollider.gameObject;//устанавливаем моба как ближайшего
			}
		}
		return nearestmob; // и возвращаем его
	}

	private void OnGUI()
	{
		Vector3 screenPosition = Camera.main.WorldToScreenPoint(gameObject.transform.position); //Находим позицию объекта на экране относительно мира
		Vector3 cameraRelative = Camera.main.transform.InverseTransformPoint(transform.position); //Получаем дальность объекта от камеры
		if (cameraRelative.z > 0) //если объект находится впереди камеры
		{
			string ammoString;
			if (ammoAmount > 0)
			{
				ammoString = ammoAmount + "/" + ammoAmountConst;
			}
			else
			{
				ammoString = "Reloading: " + (int)ammoReloadTimer + " s left";
			}
			GUI.Label(new Rect(screenPosition.x, Screen.height - screenPosition.y, 250f, 20f), ammoString);
		}
	}
}

Как видно, тут используется расчёт через квадрат расстояния и сравнение его с квадратом максимальной дистанции для пушки. Это работает быстрее, т.к. не используется Sqrt. Спасибо Leopotam за совет :)

Следующим шагом приведём сцену примерно в следующий вид:

Как создать простую Tower Defense игру на Unity3D, часть вторая

Красными точками я обозначил места спаунпойнтов, по центру у меня находится «база» в виде стандартного максовского чайника :)

Как создать простую Tower Defense игру на Unity3D, часть вторая

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

BaseHP.cs

using UnityEngine;
 
public class BaseHP : MonoBehaviour
{
   public float maxHP = 1000;
   public float curHP = 1000;
   public float regenerationDelayConstant = 2.5f; //константа задержки между регенерацией хп базы
   public float regenarationDelayVariable = 2.5f; //переменная той же задержки
   public float regenerationAmount = 10.0f; //количество восстанавливаемого хп при регенерации за раз
 
   private GlobalVars gv;
 
   private void Awake()
   {
      gv = GameObject.Find("GlobalVars").GetComponent<GlobalVars>();
      if (maxHP < 1) maxHP = 1;
   }
 
   public void ChangeHP(float adjust)
   {
      if ((curHP + adjust) > maxHP) curHP = maxHP;
      else curHP += adjust;
      if (curHP > maxHP) curHP = maxHP; //just in case
   }
 
   private void Update()
   {
      if (curHP <= 0)
      {
         Destroy(gameObject);
      }
      else
      {
         if (regenarationDelayVariable > 0) regenarationDelayVariable -= Time.deltaTime; //если переменная задержки более нуля - отнимаем от неё единицу в секунду
         if (regenarationDelayVariable <= 0) //если она стала меньше или равна нулю
         {
            ChangeHP(regenerationAmount); //восстанавливаем ранее указанное количество ХП
            regenarationDelayVariable = regenerationDelayConstant; //и возвращаем нашу переменную в её первоначальное значение
         }
      }
   }
}

Вешаем скрипт на наш объект с базой. Она готова, можно приступить к переобучению мобов!

В скрипте AI мобов изменению подлежит только метод Update, потому остальной код приводить не буду:

MobAI.cs

private void Update()
   {
      if (Target != null)
      {
         mob.rotation = Quaternion.Lerp(mob.rotation, Quaternion.LookRotation(new Vector3(Target.transform.position.x, 0.0f, Target.transform.position.z) - new Vector3(mob.position.x, 0.0f, mob.position.z)), mobRotationSpeed);
         mob.position += mob.forward * MobCurrentSpeed * Time.deltaTime;
         float squaredDistance = (Target.transform.position - mob.position).sqrMagnitude; //меряем дистанцию до цели
         Vector3 structDirection = (Target.transform.position - mob.position).normalized;
         float attackDirection = Vector3.Dot(structDirection, mob.forward);
         if (squaredDistance < attackDistance * attackDistance && attackDirection > 0)
         {
            if (attackTimer > 0) attackTimer -= Time.deltaTime;
            if (attackTimer <= 0)
            {
               BaseHP bhp = Target.GetComponent<BaseHP>(); //подключаемся к компоненту HP цели
               if (bhp != null) bhp.ChangeHP(-damage); // отнимаем от её HP наш урон
               attackTimer = coolDown;
            }
         }
      }
      else
      {
         GameObject baseGO = GameObject.FindGameObjectWithTag("Base"); //находим наш объект с базой, он всего один
         if (baseGO != null) Target = baseGO; //если она существует - делаем её целью для моба.
      }
   }

Всё хорошо, мобы ползут кусать базу, пушки методично отстреливают нахалов. Но камера-то статичная! Непорядок, исправляем:

CameraControl.cs

using UnityEngine;
 
public class CameraControl : MonoBehaviour
{
   public float CameraSpeed = 100.0f; //Скорость движения камеры
   public float CameraSpeedBoostMultiplier = 2.0f; //Множитель ускорения движения камеры при зажатом Shift
 
   //Задаём позицию по умолчанию для камеры, здесь выставлена моя - меняйте под себя
   public float DefaultCameraPosX = 888.0f;
   public float DefaultCameraPosY = 50.0f;
   public float DefaultCameraPosZ = 1414.0f;
 
   private void Awake()
   {
      //Задаём позицию по умолчанию для камеры, используя ранее указанные координаты
      transform.position = new Vector3(DefaultCameraPosX, DefaultCameraPosY, DefaultCameraPosZ);
   }
 
   private void Update()
   {
      float smoothCamSpeed = CameraSpeed * Time.smoothDeltaTime; //множим скорость перемещения камеры на сглаженную версию Time.deltaTime
 
      //При нажатии какой-либо из кнопки из WASD происходит перемещение в соответствующую сторону, нажания сразу двух кнопок также обрабатываются (WA будет двигать камеру вверх и влево), зажатие Shift при этом ускоряет передвижение.
      if (Input.GetKey(KeyCode.W)) transform.position += Input.GetKey(KeyCode.LeftShift) ? new Vector3(0.0f, 0.0f, smoothCamSpeed * CameraSpeedBoostMultiplier) : new Vector3(0.0f, 0.0f, smoothCamSpeed); //вверх
      if (Input.GetKey(KeyCode.A)) transform.position += Input.GetKey(KeyCode.LeftShift) ? new Vector3(-smoothCamSpeed * CameraSpeedBoostMultiplier, 0.0f, 0.0f) : new Vector3(-smoothCamSpeed, 0.0f, 0.0f); //налево
      if (Input.GetKey(KeyCode.S)) transform.position += Input.GetKey(KeyCode.LeftShift) ? new Vector3(0.0f, 0.0f, -smoothCamSpeed * CameraSpeedBoostMultiplier) : new Vector3(0.0f, 0.0f, -smoothCamSpeed); //вниз
      if (Input.GetKey(KeyCode.D)) transform.position += Input.GetKey(KeyCode.LeftShift) ? new Vector3(smoothCamSpeed * CameraSpeedBoostMultiplier, 0.0f, 0.0f) : new Vector3(smoothCamSpeed, 0.0f, 0.0f); //направо
   }
}

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

Следующий багфикс состоит в том, что мы может покупать пушки «в кредит». Да, нужна простая проверка денег игрока и стоимости пушки. Правим это дело:
Graphic.cs

private void OnGUI()
   {
      GUI.Box(buyMenu, "Buying menu"); //Делаем гуевский бокс на квадрате buyMenu с заголовком, указанным между ""
      if (GUI.Button(firstTower, "Plasma Towern" + (int)TowerPrices.Plasma + "$")) //если идёт нажатие на первую кнопку
      {
         if (gv.PlayerMoney >= (int)TowerPrices.Plasma) //если у игрока достаточно денег
            gv.mau5tate = GlobalVars.ClickState.Placing; //меняем глобальное состояние мыши на "Установка пушки"
      }
      if (GUI.Button(secondTower, "Pulse Towern" + (int)TowerPrices.Pulse + "$")) //с остальными аналогично
      {
         //same action here
      }
      if (GUI.Button(thirdTower, "Beam Towern" + (int)TowerPrices.Beam + "$"))
      {
         //same action here
      }
      if (GUI.Button(fourthTower, "Tesla Towern" + (int)TowerPrices.Tesla + "$"))
      {
         //same action here
      }
      if (GUI.Button(fifthTower, "Artillery Towern" + (int)TowerPrices.Artillery + "$"))
      {
         //same action here
      }
 
      GUI.Box(playerStats, "Player Stats");
      GUI.Label(playerStatsPlayerMoney, "Money: " + gv.PlayerMoney + "$");
 
      GUI.Box(towerMenu, "Tower menu");
      if (GUI.Button(towerMenuSellTower, "Sell"))
      {
         //action here
      }
      if (GUI.Button(towerMenuUpgradeTower, "Upgrade"))
      {
         //same action here
      }
   }
 
   //цены на пушки
   private enum TowerPrices
   {
      Plasma = 100,
      Pulse = 150,
      Beam = 250,
      Tesla = 300,
      Artillery = 350
   }

Далее, уже после написания всего предыдущего кода, я избавился от объекта GlobalVars, сделав его и все его переменные static.

GlobalVars.cs

using System.Collections.Generic;
using UnityEngine;

public static class GlobalVars
{
	public static List<GameObject> MobList = new List<GameObject>(); //массив мобов в игре
	public static int MobCount = 0; //счетчик мобов в игре

	public static List<GameObject> TurretList = new List<GameObject>(); //массив пушек в игре
	public static int TurretCount = 0; //счетчик пушек в игре

	public static float PlayerMoney = 200.0f; //при старте игры, если нету сохранённых данных про деньги игрока - их становится 200$, иначе загружается из памяти

	public static ClickState mau5tate = ClickState.Default; //дефолтное состояние курсора

	public enum ClickState //перечисление всех состояний курсора
	{
		Default, //обычное
		Placing, //устанавливаем пушку
		Selling, //продаём пушку
		Upgrading //улучшаем пушку
	}
}

Во всех классах, где использовался GlobalVars, удаляем переменные gv, их инициализацию в Awake(). Заменяем все gv на GlobalVars. Удаляем бесполезные проверки GlobalVars на null. Удаляем компонент GlobalVars из одноимённого ГО (можно сам ГО переименовать во что-то информативное, например, cfg).
Я приведу полные листинги классов с изменениями, чтобы вам было, с чем сравнить результат этой операции.

Осторожно, спойлеры к следующей части! :)
Graphic.cs

using UnityEngine;

public class Graphic : MonoBehaviour
{
	public Rect buyMenu; //квадрат меню покупки
	public Rect firstTower; //квадрат кнопки покупки первой башни
	public Rect secondTower; //квадрат кнопки покупки второй башни
	public Rect thirdTower; //квадрат кнопки покупки третьей башни
	public Rect fourthTower; //квадрат кнопки покупки четвёртой башни
	public Rect fifthTower; //квадрат кнопки покупки пятой башни

	public Rect towerMenu; //квадрат сервисного меню башни (продать/обновить)
	public Rect towerMenuSellTower; //квадрат кнопки продажи башни
	public Rect towerMenuUpgradeTower; //квадрат кнопки апгрейда башни

	public Rect playerStats; //квадрат статистики игрока
	public Rect playerStatsPlayerMoney; //квадрат зоны отображения денег игрока

	public GameObject plasmaTower; //префаб первой пушки, необходимо назначить в инспекторе
	public GameObject plasmaTowerGhost; //призрак первой пушки, необходимо назначить в инспекторе
	private RaycastHit hit; //переменная для рейкаста
	public LayerMask buildRaycastLayers = 1;
	public LayerMask upgradeRaycastLayer; //слой для апгрейда
	public LayerMask sellRaycastLayer; //слой для продажи

	private GameObject ghost; //переменная для призрака устанавливаемой пушки

	private void Awake()
	{
		buyMenu = new Rect(Screen.width - 185.0f, 10.0f, 175.0f, Screen.height - 100.0f); //задаём размеры квадратов, последовательно позиция X, Y, Ширина, Высота. X и Y указывают на левый верхний угол объекта
		firstTower = new Rect(buyMenu.x + 12.5f, buyMenu.y + 30.0f, 150.0f, 50.0f);
		secondTower = new Rect(firstTower.x, buyMenu.y + 90.0f, 150.0f, 50.0f);
		thirdTower = new Rect(firstTower.x, buyMenu.y + 150.0f, 150.0f, 50.0f);
		fourthTower = new Rect(firstTower.x, buyMenu.y + 210.0f, 150.0f, 50.0f);
		fifthTower = new Rect(firstTower.x, buyMenu.y + 270.0f, 150.0f, 50.0f);

		playerStats = new Rect(10.0f, 10.0f, 150.0f, 100.0f);
		playerStatsPlayerMoney = new Rect(playerStats.x + 12.5f, playerStats.y + 30.0f, 125.0f, 25.0f);

		towerMenu = new Rect(10.0f, Screen.height - 60.0f, 400.0f, 50.0f);
		towerMenuSellTower = new Rect(towerMenu.x + 12.5f, towerMenu.y + 20.0f, 75.0f, 25.0f);
		towerMenuUpgradeTower = new Rect(towerMenuSellTower.x + 5.0f + towerMenuSellTower.width, towerMenuSellTower.y, 75.0f, 25.0f);
	}

	private void Update()
	{
		switch (GlobalVars.mau5tate) //свитчим состояние курсора мыши
		{
			case GlobalVars.ClickState.Placing: //если он в режиме установки башен
				{
					if (ghost == null) ghost = Instantiate(plasmaTowerGhost) as GameObject; //если переменная призрака пустая - создаём в ней объект призрака башни
					else
					{
						Ray scrRay = Camera.main.ScreenPointToRay(Input.mousePosition); //создаём луч, бьющий от координат мыши по координатам в игре
						if (Physics.Raycast(scrRay, out hit, Mathf.Infinity, buildRaycastLayers)) // бьём этим лучем в заданном выше направлении (т.е. в землю)
						{
							Quaternion normana = Quaternion.FromToRotation(Vector3.up, hit.normal); //получаем нормаль от столкновения
							ghost.transform.position = hit.point; //задаём позицию пzризрака равной позиции точки удара луча по земле
							ghost.transform.rotation = normana; //тоже самое и с вращением, только не от точки, а от нормали
							if (Input.GetMouseButtonDown(0)) //при нажатии ЛКМ
							{
								GameObject tower = Instantiate(plasmaTower, ghost.transform.position, ghost.transform.rotation) as GameObject; //Спауним башенку на позиции призрака
								if (tower != null)
								{
									GlobalVars.PlayerMoney -= tower.GetComponent<PlasmaTurretAI>().towerPrice; //отнимаем лаве за башню
									GlobalVars.TurretCount++;
								}

								Destroy(ghost); //уничтожаем призрак башни
								GlobalVars.mau5tate = GlobalVars.ClickState.Default; //меняем глобальное состояние мыши на обычное
							}
						}
					}
					break;
				}
			case GlobalVars.ClickState.Upgrading:
				{
					//Ray scrRay = Camera.main.ScreenPointToRay(Input.mousePosition); //создаём луч, бьющий от координат мыши по координатам в игре
					//if (Physics.Raycast(scrRay, out hit, Mathf.Infinity)) // бьём этим лучем в заданном выше направлении (т.е. в землю)
					//{
					//	Collider[] colls = Physics.OverlapSphere(hit.point, 10.0f);
					//	float closestMobSquaredDistance = 0;
					//	GameObject nearestObject = null;
					//	if (Input.GetMouseButtonDown(0) && GlobalVars.PlayerMoney >= 1000.0f) //при нажатии ЛКМ и наличии более 1000$ денег
					//	{
					//		foreach (Collider coll in colls)
					//		{
					//			float distance = (coll.transform.position - hit.point).sqrMagnitude;
					//			if (distance < 100f || closestMobSquaredDistance == 0.0f)
					//			{
					//				closestMobSquaredDistance = distance;
					//				nearestObject = coll.gameObject;
					//			}
					//		}
					//		if (nearestObject != null)
					//		{
					//			switch (nearestObject.tag)
					//			{
					//				case "Turret":
					//					{
					//						GlobalVars.mau5tate = GlobalVars.ClickState.Default;
					//						break;
					//					}
					//				case "Base":
					//					{
					//						nearestObject.GetComponent<BaseHP>().Upgrade();
					//						GlobalVars.mau5tate = GlobalVars.ClickState.Default;
					//						break;
					//					}
					//				default:
					//					GlobalVars.mau5tate = GlobalVars.ClickState.Default;
					//					break;
					//			}
					//		}
					//	}
					//}
					break;
				}
		}
	}

	private void OnGUI()
	{
		GUI.Box(buyMenu, "Buying menu"); //Делаем гуевский бокс на квадрате buyMenu с заголовком, указанным между ""
		if (GUI.Button(firstTower, "Plasma Towern" + (int)TowerPrices.Plasma + "$")) //если идёт нажатие на первую кнопку
		{
			if (GlobalVars.PlayerMoney >= (int)TowerPrices.Plasma) //если у игрока достаточно денег
				GlobalVars.mau5tate = GlobalVars.ClickState.Placing; //меняем глобальное состояние мыши на "Установка пушки"
		}
		if (GUI.Button(secondTower, "Pulse Towern" + (int)TowerPrices.Pulse + "$")) //с остальными аналогично
		{
			//action here
		}
		if (GUI.Button(thirdTower, "Beam Towern" + (int)TowerPrices.Beam + "$"))
		{
			//action here
		}
		if (GUI.Button(fourthTower, "Tesla Towern" + (int)TowerPrices.Tesla + "$"))
		{
			//action here
		}
		if (GUI.Button(fifthTower, "Artillery Towern" + (int)TowerPrices.Artillery + "$"))
		{
			//action here
		}

		GUI.Box(playerStats, "Player Stats");
		GUI.Label(playerStatsPlayerMoney, "Money: " + GlobalVars.PlayerMoney + "$");

		GUI.Box(towerMenu, "Tower menu");
		if (GUI.Button(towerMenuSellTower, "Sell"))
		{
			//action here
		}
		if (GUI.Button(towerMenuUpgradeTower, "Upgrade"))
		{
			//if (GlobalVars.mau5tate == GlobalVars.ClickState.Default)
			//{
			//	GlobalVars.mau5tate = GlobalVars.ClickState.Upgrading;
			//}
		}
	}

	//цены на пушки
	private enum TowerPrices
	{
		Plasma = 100,
		Pulse = 150,
		Beam = 250,
		Tesla = 300,
		Artillery = 350
	}
}

MobHP.cs

using UnityEngine;

public class MobHP : MonoBehaviour
{
	public float maxHP = 100; //Максимум ХП
	public float curHP = 100; //Текущее ХП
	public Color MaxDamageColor = Color.red; //цвета полностью побитого
	public Color MinDamageColor = Color.blue; //и целого моба
	public GameObject explosionPrefab;

	private void Awake()
	{
		GlobalVars.MobList.Add(gameObject); //добавляем себя в общий лист мобов
		GlobalVars.MobCount++; //увеличиваем счетчик мобов
		if (maxHP < 1) maxHP = 1; //если максимальное хп задано менее единицы - ставим единицу
	}

	public void ChangeHP(float adjust) //метод корректировки ХП моба
	{
		if ((curHP + adjust) > maxHP) curHP = maxHP;//если сумма текущего ХП и adjust в результате более, чем максимальное хп - текущее ХП становится равным максимальному
		else curHP += adjust; //иначе просто добавляем adjust
	}

	private void Update()
	{
		gameObject.renderer.material.color = Color.Lerp(MaxDamageColor, MinDamageColor, curHP / maxHP); //Лерпим цвет моба по заданным в начале цветам. В примере: красный - моб почти полностью убит, синий - целый.
		if (curHP <= 0) //если ХП упало в ноль или ниже
		{
			MobAI mai = gameObject.GetComponent<MobAI>(); //подключаемся к компоненту AI моба
			if (mai != null)
			{
				GlobalVars.PlayerMoney += mai.mobPrice; //если он существует - добавляем денег игроку в размере цены за голову моба
			}

			if (explosionPrefab != null)
			{
				Instantiate(explosionPrefab, gameObject.transform.position, Quaternion.identity);
			}

			Destroy(gameObject); //удаляем себя
		}
	}

	private void OnDestroy() //при удалении
	{
		GlobalVars.MobList.Remove(gameObject); //удаляем себя из глобального списка мобов
		GlobalVars.MobCount--; //уменьшаем глобальный счетчик мобов на 1
	}
}

Здесь просто вырезана переменная и её инициализация, в коде не использовалась.
MobAI.cs

using UnityEngine;

public class MobAI : MonoBehaviour
{
	public GameObject Target; //текущая цель

	public float mobPrice = 5.0f; //цена за убийство моба
	public float mobMinSpeed = 0.5f; //минимальная скорость моба
	public float mobMaxSpeed = 2.0f; //максимальная скорость моба
	public float mobRotationSpeed = 2.5f; //скорость поворота моба
	public float attackDistance = 5.0f; //дистанция атаки
	public float damage = 5; //урон, наносимый мобом
	public float attackTimer = 0.0f; //переменная расчета задержки между ударами
	public const float coolDown = 2.0f; //константа, используется для сброса таймера атаки в начальное значение

	public float MobCurrentSpeed; //скорость моба, инициализируем позже
	private Transform mob; //переменная для трансформа моба

	private void Awake()
	{
		mob = transform; //присваиваем трансформ моба в переменную (повышает производительность)
		MobCurrentSpeed = Random.Range(mobMinSpeed, mobMaxSpeed); //посредством рандома выбираем скорость между минимально и максимально указанной
	}

	private void Update()
	{
		if (Target != null) //если у нас есть цель
		{
			mob.rotation = Quaternion.Lerp(mob.rotation, Quaternion.LookRotation(new Vector3(Target.transform.position.x, 0.0f, Target.transform.position.z) - new Vector3(mob.position.x, 0.0f, mob.position.z)), mobRotationSpeed); //избушка-избушка, повернись к пушке передом!
			mob.position += mob.forward * MobCurrentSpeed * Time.deltaTime; //двигаем в сторону, куда смотрит моб
			float squaredDistance = (Target.transform.position - mob.position).sqrMagnitude; //меряем дистанцию до цели
			Vector3 structDirection = (Target.transform.position - mob.position).normalized; //получаем вектор направления
			float attackDirection = Vector3.Dot(structDirection, mob.forward); //получаем вектор атаки
			if (squaredDistance < attackDistance * attackDistance && attackDirection > 0) //если мы на дистанции атаки и цель перед нами
			{
				if (attackTimer > 0) attackTimer -= Time.deltaTime; //если таймер атаки больше 0 - отнимаем его
				if (attackTimer <= 0) //если же он стал меньше нуля или равен ему
				{
					BaseHP bhp = Target.GetComponent<BaseHP>(); //подключаемся к компоненту ХП цели
					if (bhp != null) bhp.ChangeHP(-damage); //если цель ещё живая, наносим урон (мы можем не одни бить по цели, потому проверка необходима)
					attackTimer = coolDown; //возвращаем таймер в исходное положение
					MobHP mhp = GetComponent<MobHP>();
					mhp.curHP = 0;
				}
			}
		}
		else
		{
			GameObject baza = GameObject.FindGameObjectWithTag("Base"); //находим наш объект с базой, он всего один
			if (baza != null) Target = baza;
		}
	}
}

BaseHP.cs

using UnityEngine;

public class BaseHP : MonoBehaviour
{
	public float maxHP = 1000;
	public float curHP = 1000;
	public const float regenerationDelayConstant = 2.5f; //константа задержки между регенерацией хп базы
	public float regenarationDelayVariable = 2.5f; //переменная той же задержки
	public float regenerationAmount = 10.0f; //количество восстанавливаемого хп при регенерации за раз

	private void Awake()
	{
		if (maxHP < 1) maxHP = 1;
	}

	public void ChangeHP(float adjust)
	{
		if ((curHP + adjust) > maxHP) curHP = maxHP;
		else curHP += adjust;
		if (curHP > maxHP) curHP = maxHP; //just in case
	}

	private void Update()
	{
		if (curHP <= 0)
		{
			Destroy(gameObject);
		}
		else
		{
			if (regenarationDelayVariable > 0) regenarationDelayVariable -= Time.deltaTime; //если переменная задержки более нуля - отнимаем от неё единицу в секунду
			if (regenarationDelayVariable <= 0) //если она стала меньше или равна нулю
			{
				ChangeHP(regenerationAmount); //восстанавливаем ранее указанное количество ХП
				regenarationDelayVariable = regenerationDelayConstant; //и возвращаем нашу переменную в её первоначальное значение
			}
		}
	}

	private void OnGUI()
	{
		Vector3 screenPosition = Camera.main.WorldToScreenPoint(gameObject.transform.position); //Находим позицию объекта на экране относительно мира
		Vector3 cameraRelative = Camera.main.transform.InverseTransformPoint(transform.position); //Получаем дальность объекта от камеры
		if (cameraRelative.z > 0) //если объект находится впереди камеры
		{
			//отображаем количество его HP
			if (curHP > 0) GUI.Label(new Rect(screenPosition.x, Screen.height - screenPosition.y, 200f, 20f), curHP.ToString());
		}
	}
}

SpawnerAI.cs

using UnityEngine;

public class SpawnerAI : MonoBehaviour
{
	public int waveAmount = 5; //Количество мобов за 1 волну на каждой точке спауна
	public int waveNumber = 0; //переменная текущей волны
	public float waveDelayTimer = 30.0F; //переменная таймера спауна волны
	public float waveCooldown = 20.0F; //переменная (не константа уже!) для сброса таймера выше, мы её будем модифицировать
	public int maximumWaves = 500; //максимальное количество мобов в игре
	public Transform Mob; //переменная для загрузки префаба в Unity
	public GameObject[] SpawnPoints; //массив точек спауна

	private void Awake()
	{
		SpawnPoints = GameObject.FindGameObjectsWithTag("Spawnpoint"); //забираем все точки спауна в массив
	}

	private void Update()
	{
		if (waveDelayTimer > 0) //если таймеh спауна волны больше нуля
		{
			if (GlobalVars.MobCount == 0) waveDelayTimer = 0; //если мобов на сцене нет - устанавливаем его в ноль
			else waveDelayTimer -= Time.deltaTime; //иначе отнимаем таймер
		}
		if (waveDelayTimer <= 0) //если таймер менее или равен нулю
		{
			if (SpawnPoints != null && waveNumber < maximumWaves) //если имеются точки спауна и ещё не достигнут предел количества волн
			{
				foreach (GameObject spawnPoint in SpawnPoints) //на каждой точке спауна
				{
					for (int i = 0; i < waveAmount; i++) //используем i как модификатор для спауна, чтобы мобы не были в упор друг к другу
					{
						Instantiate(Mob, new Vector3(spawnPoint.transform.position.x, spawnPoint.transform.position.y, spawnPoint.transform.position.z + i * 10), Quaternion.identity); //спауним моба
					}

					if (waveCooldown > 5.0f) //если задержка длится более 5 секунд
					{
						waveCooldown -= 0.1f; //сокращаем на 0.1 секунды
						waveDelayTimer = waveCooldown; //задаём новый таймер
					}
					else //иначе
					{
						waveCooldown = 5.0f; //задержка никогда не будет менее 5 секунд
						waveDelayTimer = waveCooldown;
					}

					if (waveNumber >= 50) //после 50 волны
					{
						waveAmount = 10; //будем спаунить по 10 мобов на каждой точке
					}
				}
				waveNumber++; //увеличиваем номер волны
			}
		}
	}
}

TurretHP.cs

using UnityEngine;

public class TurretHP : MonoBehaviour
{
	public float maxHP = 100; //Максимум ХП
	public float curHP = 100; //Текущее ХП

	private void Awake()
	{
		GlobalVars.TurretList.Add(gameObject);
		GlobalVars.TurretCount++;
		if (maxHP < 1) maxHP = 1;
	}

	public void ChangeHP(float adjust)
	{
		if ((curHP + adjust) > maxHP) curHP = maxHP;
		else curHP += adjust;
		if (curHP > maxHP) curHP = maxHP;
	}

	private void Update()
	{
		if (curHP <= 0)
		{
			Destroy(gameObject);
		}
	}

	private void OnDestroy()
	{
		GlobalVars.TurretList.Remove(gameObject);
		GlobalVars.TurretCount--;
	}
}

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

myGizmos.cs

using UnityEngine;

public static class myGizmos
{
	public static void DrawCircle(Vector3 pos, Vector3 normal, int segs, float radius, bool showNormal)
	{
		float stepAng = 360.0f / Mathf.Max(segs, 1.0f);
		float curAng = stepAng;
		Quaternion rot = Quaternion.FromToRotation(Vector3.up, normal);

		while (curAng <= 360.0f)
		{
			Vector3 pPrev = rot * (Quaternion.Euler(Vector3.up * (curAng - stepAng)) * (Vector3.right * radius));
			Vector3 p = rot * (Quaternion.Euler(Vector3.up * curAng) * (Vector3.right * radius));

			Gizmos.DrawLine(pos + pPrev, pos + p);

			curAng += stepAng;
		}

		Gizmos.DrawWireSphere(pos, radius * 0.1f);

		if (showNormal) Gizmos.DrawLine(pos, pos + normal * radius);
	}

	public static void DrawArrow(Vector3 pos, Vector3 dir, Vector2 size)
	{
		Gizmos.DrawWireSphere(pos, size.x);

		Vector3 endPos = pos + dir * size.y;

		Gizmos.DrawLine(pos, endPos);

		Vector3 ax1 = Vector3.Cross(dir, Vector3.right);
		Vector3 ax2 = Vector3.Cross(dir, Vector3.forward);

		Vector3 hpB = endPos - (dir * size.y * 0.2f);
		Vector3 hp1 = hpB + ax1 * size.x;
		Vector3 hp2 = hpB - ax1 * size.x;
		Vector3 hp3 = hpB + ax2 * size.x;
		Vector3 hp4 = hpB - ax2 * size.x;

		Gizmos.DrawLine(endPos, hp1);
		Gizmos.DrawLine(endPos, hp2);
		Gizmos.DrawLine(endPos, hp3);
		Gizmos.DrawLine(endPos, hp4);

		Gizmos.DrawLine(hp1, hp4);
		Gizmos.DrawLine(hp1, hp3);
		Gizmos.DrawLine(hp2, hp4);
		Gizmos.DrawLine(hp2, hp3);
	}
}

GizmoOnObject.cs

using UnityEngine;

public class GizmoOnObject : MonoBehaviour
{
	public Color GizmoColor = Color.yellow;
	public float GizmoRadius = 40.0f;
	public int GizmoSegments = 36;
	public bool ShowNormal;

	public void OnDrawGizmosSelected()
	{
		Gizmos.color = GizmoColor;
		myGizmos.DrawCircle(transform.position, Vector3.up, GizmoSegments, GizmoRadius, ShowNormal);
	}
}

Как использовать: надеваете скрипт на объект и в инспекторе регулируете дальность. Вокруг ГО при выделении появится желтый круг, это и есть указанная дальность.

Заключение

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

Пишите в комментариях, какие ещё моменты вам хочется видеть в третьей части.

Продолжение следует…

Автор: Andy_Ion

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js