Здравствуйте! Весьма надолго у меня растянулась подготовка материала (жизнь давала изрядных пинков под зад), но вот я справился и готов поделиться продолжением первой статьи с вами.
Часть первая
Неудачный тест физики
В этой части мы:
— оптимизируем код из предыдущей статьи;
— создадим объект «база» и научим её чиниться время от времени;
— добавим пушкам патроны и перезарядку;
— избавимся от «неудобной» переменной gv;
А в конце статьи вас ожидает маленький бонус :)
Всем заинтересовавшимся — добро пожаловать под долгожданный кат!
В этой части туториала мы оптимизируем ранее написанный намиговнокод, что даст нам запас производительности для игры.
Начнём же мы с AI скрипта пушки, изменения в котором коснулись способа расчёта расстояния, появилась обойма с патронами, и перезарядка, длящаяся указанное время:
PlasmaTurretAI.cs
Как видно, тут используется расчёт через квадрат расстояния и сравнение его с квадратом максимальной дистанции для пушки. Это работает быстрее, т.к. не используется Sqrt. Спасибо Leopotam за совет :)
Следующим шагом приведём сцену примерно в следующий вид:
Красными точками я обозначил места спаунпойнтов, по центру у меня находится «база» в виде стандартного максовского чайника :)
На базу я повесил тег Base, чтобы можно было её легко найти.
Нам нужно сделать так, чтобы мобы шли прямо к базе, игнорируя пушки. Для этого нужно научить базу понимать урон и чиниться через определённые интервалы.
Что ж, начнём:
BaseHP.cs
Вешаем скрипт на наш объект с базой. Она готова, можно приступить к переобучению мобов!
В скрипте AI мобов изменению подлежит только метод Update, потому остальной код приводить не буду:
MobAI.cs
Всё хорошо, мобы ползут кусать базу, пушки методично отстреливают нахалов. Но камера-то статичная! Непорядок, исправляем:
CameraControl.cs
Скрипт, само собой, вешается на камеру. Теперь всё двигается, можно поглядеть вокруг на подходящих к базе мобов, ставить пушки ещё на подходе.
Следующий багфикс состоит в том, что мы можем покупать пушки «в кредит». Да, нужна простая проверка денег игрока и стоимости пушки. Правим это дело:
Graphic.cs
Далее, уже после написания всего предыдущего кода, я избавился от объекта GlobalVars, сделав его и все его переменные static.
GlobalVars.cs
Во всех классах, где использовался GlobalVars, удаляем переменные gv, их инициализацию в Awake(). Заменяем все gv на GlobalVars. Удаляем бесполезные проверки GlobalVars на null. Удаляем компонент GlobalVars из одноимённого ГО (можно сам ГО переименовать во что-то информативное, например, cfg).
Я приведу полные листинги классов с изменениями, чтобы вам было, с чем сравнить результат этой операции.
Осторожно, спойлеры к следующей части! :)
bitbucket.org/andyion/habratd-tutorial/commits/db7c1bc0c10c89f45be187e59e0608a2fbb3083d
На этом замена завершена.
Следующим моментом добавлю маленький бонус, который значительно облегчит жизнь при регулировке дальности атаки как пушкам, так и мобам: bitbucket.org/andyion/habratd-tutorial/commits/18ec053f5f5697abbd3598890aa40306e038d472
Как использовать: надеваете скрипт на объект и в инспекторе регулируете дальность. Вокруг ГО при выделении появится желтый круг, это и есть указанная дальность.
В заключении хочется сказать, что несмотря на до сих пор присутствующие косяки в коде, из этого можно создать вполне рабочий прототип игры. Я так и не успел поковыряться с NavMesh, но на первый взгляд — ничего сложного.
Часть первая
Неудачный тест физики
В этой части мы:
— оптимизируем код из предыдущей статьи;
— создадим объект «база» и научим её чиниться время от времени;
— добавим пушкам патроны и перезарядку;
— избавимся от «неудобной» переменной 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 за совет :)
Следующим шагом приведём сцену примерно в следующий вид:
Красными точками я обозначил места спаунпойнтов, по центру у меня находится «база» в виде стандартного максовского чайника :)
На базу я повесил тег 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 Tower\n" + (int)TowerPrices.Plasma + "$")) //если идёт нажатие на первую кнопку
{
if (gv.PlayerMoney >= (int)TowerPrices.Plasma) //если у игрока достаточно денег
gv.mau5tate = GlobalVars.ClickState.Placing; //меняем глобальное состояние мыши на "Установка пушки"
}
if (GUI.Button(secondTower, "Pulse Tower\n" + (int)TowerPrices.Pulse + "$")) //с остальными аналогично
{
//same action here
}
if (GUI.Button(thirdTower, "Beam Tower\n" + (int)TowerPrices.Beam + "$"))
{
//same action here
}
if (GUI.Button(fourthTower, "Tesla Tower\n" + (int)TowerPrices.Tesla + "$"))
{
//same action here
}
if (GUI.Button(fifthTower, "Artillery Tower\n" + (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).
Я приведу полные листинги классов с изменениями, чтобы вам было, с чем сравнить результат этой операции.
Осторожно, спойлеры к следующей части! :)
bitbucket.org/andyion/habratd-tutorial/commits/db7c1bc0c10c89f45be187e59e0608a2fbb3083d
На этом замена завершена.
Следующим моментом добавлю маленький бонус, который значительно облегчит жизнь при регулировке дальности атаки как пушкам, так и мобам: bitbucket.org/andyion/habratd-tutorial/commits/18ec053f5f5697abbd3598890aa40306e038d472
Как использовать: надеваете скрипт на объект и в инспекторе регулируете дальность. Вокруг ГО при выделении появится желтый круг, это и есть указанная дальность.
Заключение
В заключении хочется сказать, что несмотря на до сих пор присутствующие косяки в коде, из этого можно создать вполне рабочий прототип игры. Я так и не успел поковыряться с NavMesh, но на первый взгляд — ничего сложного.