Создание игры Tower Defense в Unity — Часть 2

https://www.raywenderlich.com/168078/create-tower-defense-game-unity-part-2
  • Перевод
image

Это вторая часть туториала «Создание игры Tower Defense в Unity». Мы создаём в Unity игру жанра tower defense, и к концу первой части, научились размещать и апгрейдить монстров. Также у нас есть один враг, нападающий на печенье.

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

Приступаем к работе


Откройте в Unity проект, на котором мы остановились в прошлой части. Если вы присоединились к нам только сейчас, то скачайте проект-заготовку и откройте TowerDefense-Part2-Starter.

Откройте GameScene из папки Scenes.

Поворачиваем врагов


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

Откройте в IDE скрипт MoveEnemy.cs и добавьте в него следующий метод, чтобы исправить ситуацию.

private void RotateIntoMoveDirection()
{
  //1
  Vector3 newStartPosition = waypoints [currentWaypoint].transform.position;
  Vector3 newEndPosition = waypoints [currentWaypoint + 1].transform.position;
  Vector3 newDirection = (newEndPosition - newStartPosition);
  //2
  float x = newDirection.x;
  float y = newDirection.y;
  float rotationAngle = Mathf.Atan2 (y, x) * 180 / Mathf.PI;
  //3
  GameObject sprite = gameObject.transform.Find("Sprite").gameObject;
  sprite.transform.rotation = Quaternion.AngleAxis(rotationAngle, Vector3.forward);
}

RotateIntoMoveDirection поворачивает врага так, чтобы всегда смотрел вперёд. Он делает это следующим образом:

  1. Вычисляет текущее направление движения жука, вычитая позицию текущей точки маршрута из позиции следующей точки.
  2. Использует Mathf.Atan2 для определения угла в радианах, в котором направлен newDirection (нулевая точка находится справа). Умножает результат на 180 / Mathf.PI, преобразуя угол в градусы.
  3. Наконец, он получает дочерний объект Sprite и поворачивает на rotationAngle градусов по оси. Заметьте, что мы поворачиваем дочерний, а не родительский объект, чтобы полоска энергии, которую мы добавим позже, оставались горизонтальной.

В Update(), заменим комментарий // TODO: поворот в направлении движения следующим вызовом RotateIntoMoveDirection:

RotateIntoMoveDirection();

Сохраните файл и вернитесь в Unity. Запустите сцену; теперь враг знает, куда он движется.


Теперь жук знает, куда он движется.

Один-единственный враг выглядит не очень впечатляюще. Нам нужны орды! И как в любой игре tower defense, орды набегают волнами!

Информируем игрока


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

Информация о волнах требуется нескольким GameObject, поэтому мы добавим её к компоненту GameManagerBehavior объекта GameManager.

Откройте в IDE GameManagerBehavior.cs и добавьте две следующие переменные:

public Text waveLabel;
public GameObject[] nextWaveLabels;

waveLabel хранит ссылку на метку вывода номера волны в верхнем правом углу экрана. nextWaveLabels хранит два GameObject, создающие в сочетании анимацию, которую мы будем показывать в начале новой волны:


Сохраните файл и вернитесь в Unity. Выберите GameManager в Hierarchy. Нажмите на кружок справа от Wave Label и в диалоговом окне Select Text выберите WaveLabel из вкладки Scene.

Теперь задайте Size для Next Wave Labels значение 2. Теперь выберите для Element 0 значение NextWaveBottomLabel, а для Element 1 значение NextWaveTopLabel так же, как мы сделали с Wave Label.


Вот как теперь должен выглядеть Game Manager Behavior

Если игрок проиграет, то он не должен увидеть сообщение о следующей волне. Чтобы обработать эту ситуацию, вернитесь в GameManagerBehavior.cs и добавьте ещё одну переменную:

public bool gameOver = false;

В gameOver мы будем хранить значение того, проиграл ли игрок.

Здесь мы снова используем свойство для синхронизации элементов игры с текущей волной. Добавьте в GameManagerBehavior следующий код:

private int wave;
public int Wave
{
  get
  {
    return wave;
  }
  set
  {
    wave = value;
    if (!gameOver)
    {
      for (int i = 0; i < nextWaveLabels.Length; i++)
      {
        nextWaveLabels[i].GetComponent<Animator>().SetTrigger("nextWave");
      }
    }
    waveLabel.text = "WAVE: " + (wave + 1);
  }
}

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

Мы присваиваем wave новое значение value.

Затем проверяем, не закончена ли игра. Если нет, то обходим в цикле все метки nextWaveLabels — у этих меток есть компонент Animator. Чтобы задействовать анимацию Animator мы задаём триггер nextWave.

Наконец, мы присваиваем text у waveLabel значение wave + 1. Почему +1? Обычные люди не начинают отсчёт с нуля (да, это странно).

В Start() задаём значение этого свойства:

Wave = 0;

Мы начинаем счёт с номера 0 Wave.

Сохраните файл и запустите сцену в Unity. Метка Wave будет правильно показывать 1.


Для игрока всё начинается с волны 1.

Волны: создаём кучи врагов


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

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

Задание тегов врагов


Выберите префаб Enemy в Project Browser. В верхней части Inspector нажмите на раскрывающийся список Tag и выберите Add Tag.


Создайте Tag с названием Enemy.


Выберите префаб Enemy. В Inspector задайте для него тег Enemy.

Задание волн врагов


Теперь нам нужно задать волну врагов. Откройте в IDE SpawnEnemy.cs и добавьте перед SpawnEnemy следующую реализацию класса:

[System.Serializable]
public class Wave
{
  public GameObject enemyPrefab;
  public float spawnInterval = 2;
  public int maxEnemies = 20;
}

Wave содержит enemyPrefab — основу для создания экземпляров всех врагов в этой волне, spawnInterval — время между врагами в волне в секундах и maxEnemies — количество врагов, создаваемых в этой волне.

Класс является Serializable, то есть мы можем изменять его значения в Inspector.

Добавьте в класс SpawnEnemy следующие переменные:

public Wave[] waves;
public int timeBetweenWaves = 5;

private GameManagerBehavior gameManager;

private float lastSpawnTime;
private int enemiesSpawned = 0;

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

Мы задаём волны отдельные врагов в waves и отслеживаем количество создаваемых врагов и время их создания в enemiesSpawned и lastSpawnTime.

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

Заменим содержимое Start() следующим кодом.

lastSpawnTime = Time.time;
gameManager =
    GameObject.Find("GameManager").GetComponent<GameManagerBehavior>();

Здесь мы присваиваем lastSpawnTime значение текущего времени, то есть время запуска скрипта после загрузки сцены. Затем мы получаем знакомым уже способом GameManagerBehavior.

Добавим в Update() следующий код:

// 1
int currentWave = gameManager.Wave;
if (currentWave < waves.Length)
{
  // 2
  float timeInterval = Time.time - lastSpawnTime;
  float spawnInterval = waves[currentWave].spawnInterval;
  if (((enemiesSpawned == 0 && timeInterval > timeBetweenWaves) ||
       timeInterval > spawnInterval) && 
      enemiesSpawned < waves[currentWave].maxEnemies)
  {
    // 3  
    lastSpawnTime = Time.time;
    GameObject newEnemy = (GameObject)
        Instantiate(waves[currentWave].enemyPrefab);
    newEnemy.GetComponent<MoveEnemy>().waypoints = waypoints;
    enemiesSpawned++;
  }
  // 4 
  if (enemiesSpawned == waves[currentWave].maxEnemies &&
      GameObject.FindGameObjectWithTag("Enemy") == null)
  {
    gameManager.Wave++;
    gameManager.Gold = Mathf.RoundToInt(gameManager.Gold * 1.1f);
    enemiesSpawned = 0;
    lastSpawnTime = Time.time;
  }
  // 5 
}
else
{
  gameManager.gameOver = true;
  GameObject gameOverText = GameObject.FindGameObjectWithTag ("GameWon");
  gameOverText.GetComponent<Animator>().SetBool("gameOver", true);
}

Разберём его пошагово:

  1. Получаем индекс текущей волны и проверяем, последняя ли она.
  2. Если да, то вычисляем время, прошедшее после предыдущего спауна врагов и проверяем, настало ли время создавать врага. Здесь мы учитываем два случая. Если это первый враг в волне, то мы проверяем, больше ли timeInterval, чем timeBetweenWaves. В противном случае мы проверяем, больше ли timeInterval, чем spawnInterval волны. В любом случае мы проверяем, что не создали всех врагов в этой волне.
  3. При необходимости спауним врага, создавая экземпляр enemyPrefab. Также увеличиваем значение enemiesSpawned.
  4. Проверяем количество врагов на экране. Если их нет, и это был последний враг в волне, то создаём следующую волну. Также в конце волны мы даём игроку 10 процентов всего оставшегося золота.
  5. После победы над последней волной здесь воспроизводится анимация победы в игре.

Задание интервалов спауна


Сохраните файл и вернитесь в Unity. Выберите в Hierarchy объект Road. В Inspector задайте для Size объекта Waves значение 4.

Пока выберем для всех четырёх элементов в качестве Enemy Prefab объект Enemy. Настройте поля Spawn Interval и Max Enemies следующим образом:

  • Element 0: Spawn Interval: 2.5, Max Enemies: 5
  • Element 1: Spawn Interval: 2, Max Enemies: 10
  • Element 2: Spawn Interval: 2, Max Enemies: 15
  • Element 3: Spawn Interval: 1, Max Enemies: 5

Готовая схема должна выглядеть так:


Разумеется, вы можете поэкспериментировать с этими значениями, чтобы увеличить или уменьшить сложность.

Запустите игру. Ага! Жуки начали путь к вашей печеньке!

bugs

Дополнительная задача: добавим разные типы врагов


Никакая игра жанра tower defense не может считаться полной только с одним типов врагов. К счастью, в папке Prefabs есть ещё и Enemy2.

В Inspector выберите Prefabs\Enemy2 и добавьте к нему скрипт MoveEnemy. Задайте для Speed значение 3 и задайте тег Enemy. Теперь можно использовать этого быстрого врага, чтобы игрок не расслаблялся!

Обновление жизни игрока


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

Откройте в IDE GameManagerBehavior.cs и добавьте две следующие переменные:

public Text healthLabel;
public GameObject[] healthIndicator;

Мы используем healthLabel для доступа к значению жизни игрока, а healthIndicator для доступа к пяти маленьким зелёным монстрам, жующим печенье — они просто символизируют здоровье игрока; это забавнее, чем стандартный индикатор здоровья.

Управление здоровьем


Теперь добавим свойство, хранящее здоровье игрока в GameManagerBehavior:

private int health;
public int Health
{
  get
  {
    return health;
  }
  set
  {
    // 1
    if (value < health)
    {
      Camera.main.GetComponent<CameraShake>().Shake();
    }
    // 2
    health = value;
    healthLabel.text = "HEALTH: " + health;
    // 3
    if (health <= 0 && !gameOver)
    {
      gameOver = true;
      GameObject gameOverText = GameObject.FindGameObjectWithTag("GameOver");
      gameOverText.GetComponent<Animator>().SetBool("gameOver", true);
    }
    // 4 
    for (int i = 0; i < healthIndicator.Length; i++)
    {
      if (i < Health)
      {
        healthIndicator[i].SetActive(true);
      }
      else
      {
        healthIndicator[i].SetActive(false);
      }
    }
  }
}

Так мы управляем здоровьем игрока. И снова основная часть кода расположена в сеттере:

  1. Если мы снижаем здоровье игрока, то используем компонент CameraShake для создания красивого эффекта тряски. Этот скрипт включён в скачиваемый проект и мы не будем его здесь рассматривать.
  2. Обновляем частную переменную и метку здоровья в верхнем левом углу экрана.
  3. Если здоровье снизилось до 0 и конец игры ещё не наступил, то присваиваем gameOver значение true и запускаем анимацию GameOver.
  4. Убираем одного из монстров с печенья. Если мы просто отключаем их, то эту часть можно написать проще, но здесь мы поддерживаем повторное включение на случай добавления здоровья.

Инициализируем Health в Start():

Health = 5;

Мы присваиваем Health значение 5, когда начинает воспроизводиться сцена.

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

Изменение здоровья


Для изменения здоровья найдите комментарий в Update() со словами // TODO: вычитать здоровье и замените его таким кодом:

GameManagerBehavior gameManager =
    GameObject.Find("GameManager").GetComponent<GameManagerBehavior>();
gameManager.Health -= 1;

Так мы получаем GameManagerBehavior и вычитаем из его Health единицу.

Сохраните файл и вернитесь в Unity.

Выберите в Hierarchy объект GameManager и выберите для его Health Label значение HealthLabel.

Разверните в Hierarchy объект Cookie и перетащите пять его дочерних HealthIndicator в массив GameManager's Health Indicator — индикаторами здоровья будут мелкие зелёные монстрики, поедающие печенье.

Запустите сцену и дождитесь, пока жуки не дойдут до печеньки. Ничего не делайте, пока не проиграете.

cookie-attack

Месть монстров


Монстры на месте? Да. Враги нападают? Да, и они выглядят угрожающе! Настала пора ответить этим животным!

Для этого нам нужно следующее:

  • Полоса здоровья, чтобы игрок знал, какие враги сильные, а какие слабые
  • Обнаружение врагов в радиусе действия монстра
  • Принятие решения — в какого врага нужно стрелять
  • Куча снарядов

Полоса здоровья врагов


Для реализации полосы здоровья мы используем два изображения — одно для тёмного фона, а второе (зелёную полосу немного поменьше) будем масштабировать в соответствии со здоровьем врага.

Перетащите из Project Browser в сцену Prefabs\Enemy.

Затем в Hierarchy перетащите Images\Objects\HealthBarBackground на Enemy, чтобы добавить его как дочерний элемент.

В Inspector задайте для Position у HealthBarBackground значение (0, 1, -4).

Затем в Project Browser выберите Images\Objects\HealthBar и убедитесь, что его Pivot имеет значение Left. Затем добавьте его как дочерний элемент Enemy в Hierarchy и задайте его Position значение (-0.63, 1, -5). Для X масштаба Scale задайте значение 125.

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

Выбрав в Hierarchy объект Enemy, убедитесь, что его позиция равна (20, 0, 0).

Нажмите Apply в верхней части Inspector, чтобы сохранить все изменения как часть префаба. Наконец удалите в Hierarchy объект Enemy.


Теперь повторите все эти шаги, чтобы добавить полосу здоровья для Prefabs\Enemy2.

Изменяем длину полосы здоровья


Откройте в IDE HealthBar.cs и добавьте следующие переменные:

public float maxHealth = 100;
public float currentHealth = 100;
private float originalScale;

В maxHealth хранится максимальное здоровье врага, а в currentHealth — оставшееся здоровье. Наконец, в originalScale находится начальный размер полосы здоровья.

Сохраняем originalScale объекта в Start():

originalScale = gameObject.transform.localScale.x;

Мы сохраняем значение x свойства localScale.

Зададим масштаб полосы здоровья, добавив следующий код в Update():

Vector3 tmpScale = gameObject.transform.localScale;
tmpScale.x = currentHealth / maxHealth * originalScale;
gameObject.transform.localScale = tmpScale;

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

Сохраните файл и запустите игру в Unity. Над врагами вы увидите полосы здоровья.


Пока игра запущена, разверните в Hierarchy один из объектов Enemy(Clone) и выберите его дочерний элемент HealthBar. Измените его значение Current Health и посмотрите, как меняется полоса его здоровья.


Обнаружение врагов в радиусе действия


Теперь нашим монстрам нужно узнать, в каких врагов целиться. Но прежде чем реализовать эту возможность, необходимо подготовить Monster и Enemy.

Выберите в Project Browser Prefabs\Monster и добавьте к нему в Inspector компонент Circle Collider 2D.

Задайте для параметра Radius коллайдера значение 2.5 — так мы укажем радиус атаки монстров.

Поставьте флажок Is Trigger, чтобы объекты проходили сквозь эту область, а не сталкивались с ней.

Наконец в верхней части Inspector, выберите для Layer объекта Monster значение Ignore Raycast. Нажмите в диалоговом окне на Yes, change children. Если не выбрать Ignore Raycast, то коллайдер будет реагировать на события нажатий мыши. Это будет проблемой, потому что монстры блокируют события, предназначенные для объектов Openspot под ними.


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

В Project Browser выберите Prefabs\Enemy. Добавьте компонент Rigidbody 2D и выберите для Body Type значение Kinematic. Это означает, что на тело не будет воздействовать физика.

Добавьте Circle Collider 2D с Radius, равным 1. Повторите эти шаги для Prefabs\Enemy 2.

Триггеры настроены, поэтому монстры будут понимать, что враги находятся в радиусе их действия.

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

Создайте новый скрипт C# с названием EnemyDestructionDelegate и добавьте его к префабам Enemy и Enemy2.

Откройте в IDE EnemyDestructionDelegate.cs и добавьте следующее объявление делегирования:

public delegate void EnemyDelegate (GameObject enemy);
public EnemyDelegate enemyDelegate;

Здесь мы создаём delegate (делегат), то есть контейнер для функции, которую можно передавать как переменную.

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

Добавьте следующий метод:

void OnDestroy()
{
  if (enemyDelegate != null)
  {
    enemyDelegate(gameObject);
  }
}

При уничтожении игрового объекта Unity автоматически вызывает этот метод и проверяет делегата на неравенство null. В нашем случае мы вызываем его с gameObject в качестве параметра. Это позволяет всем респондентам, зарегистрированным как делегаты, знать, что враг уничтожен.

Сохраните файл и вернитесь в Unity.

Даём монстрам лицензию на убийство


И теперь монстры могут обнаруживать врагов в радиусе своего действия. Добавьте к префабу Monster новый скрипт C# и назовите его ShootEnemies.

Откройте в IDE ShootEnemies.cs и добавьте в него следующую конструкцию using, чтобы получить доступ к Generics.

using System.Collections.Generic;

Добавьте переменную для слежения за всеми врагами в радиусе действия:

public List<GameObject> enemiesInRange;

В enemiesInRange мы будем хранить всех врагов, находящихся в радиусе действия.

Инициализируем поле в Start().

enemiesInRange = new List<GameObject>();

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

Заполним список enemiesInRange! Добавьте в скрипт такой код:

// 1
void OnEnemyDestroy(GameObject enemy)
{
  enemiesInRange.Remove (enemy);
}

void OnTriggerEnter2D (Collider2D other)
{
// 2
  if (other.gameObject.tag.Equals("Enemy"))
  {
    enemiesInRange.Add(other.gameObject);
    EnemyDestructionDelegate del =
        other.gameObject.GetComponent<EnemyDestructionDelegate>();
    del.enemyDelegate += OnEnemyDestroy;
  }
}
// 3
void OnTriggerExit2D (Collider2D other)
{
  if (other.gameObject.tag.Equals("Enemy"))
  {
    enemiesInRange.Remove(other.gameObject);
    EnemyDestructionDelegate del =
        other.gameObject.GetComponent<EnemyDestructionDelegate>();
    del.enemyDelegate -= OnEnemyDestroy;
  }
}

  1. В OnEnemyDestroy мы удаляем врага из enemiesInRange. Когда враг наступает на триггер вокруг монстра, вызывается OnTriggerEnter2D.
  2. Затем мы добавляем врага в список enemiesInRange и добавляем EnemyDestructionDelegate событие OnEnemyDestroy. Так мы гарантируем, что при уничтожении врага будет вызвано OnEnemyDestroy. Мы не хотим, чтобы монстры тратили боезапас на мёртвых врагов, правда?
  3. В OnTriggerExit2D мы удаляем врага из списка и разрегистрируем делегата. Теперь мы знаем, какие враги находятся в радиусе действия.

Сохраните файл и запустите игру в Unity. Чтобы убедиться, что всё работает, расположите монстра, выберите его и проследите в Inspector за изменениями в списке enemiesInRange.

Выбор цели


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

Конечно же, они будут атаковать самого близкого к печенью!

Откройте IDE скрипт MoveEnemy.cs и добавьте новый метод, вычисляющий этого монстра:

public float DistanceToGoal()
{
  float distance = 0;
  distance += Vector2.Distance(
      gameObject.transform.position, 
      waypoints [currentWaypoint + 1].transform.position);
  for (int i = currentWaypoint + 1; i < waypoints.Length - 1; i++)
  {
    Vector3 startPosition = waypoints [i].transform.position;
    Vector3 endPosition = waypoints [i + 1].transform.position;
    distance += Vector2.Distance(startPosition, endPosition);
  }
  return distance;
}

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

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

Сохраните файл и вернитесь в Unity, чтобы начать настройку снарядов.

Дадим монстрам снаряды. Много снарядов!


Перетащите из Project Browser в сцену Images/Objects/Bullet1. Задайте для позиции по z значение -2 — позиции по x и y не важны, потому что мы задаём их каждый раз, когда создаём новый экземпляр снаряда при выполнении программы.

Добавьте новый скрипт C# с названием BulletBehavior, а затем в IDE добавьте в него следующие переменные:

public float speed = 10;
public int damage;
public GameObject target;
public Vector3 startPosition;
public Vector3 targetPosition;

private float distance;
private float startTime;

private GameManagerBehavior gameManager;

speed определяет скорость полёта снарядов; назначение damage понятно из названия.

target, startPosition и targetPosition определяют направление снаряда.

distance и startTime отслеживают текущую позицию снаряда. gameManager вознаграждает игрока, когда он убивает врага.

Назначаем значения этих переменных в Start():

startTime = Time.time;
distance = Vector2.Distance (startPosition, targetPosition);
GameObject gm = GameObject.Find("GameManager");
gameManager = gm.GetComponent<GameManagerBehavior>();

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

Для управления движением снаряда добавим в Update() следующий код:

// 1 
float timeInterval = Time.time - startTime;
gameObject.transform.position = Vector3.Lerp(startPosition, targetPosition, timeInterval * speed / distance);

// 2 
if (gameObject.transform.position.Equals(targetPosition))
{
  if (target != null)
  {
    // 3
    Transform healthBarTransform = target.transform.Find("HealthBar");
    HealthBar healthBar = 
        healthBarTransform.gameObject.GetComponent<HealthBar>();
    healthBar.currentHealth -= Mathf.Max(damage, 0);
    // 4
    if (healthBar.currentHealth <= 0)
    {
      Destroy(target);
      AudioSource audioSource = target.GetComponent<AudioSource>();
      AudioSource.PlayClipAtPoint(audioSource.clip, transform.position);

      gameManager.Gold += 50;
    }
  }
  Destroy(gameObject);
}

  1. Мы вычисляем новую позицию снаряда, используя Vector3.Lerp для интерполяции между начальной и конечной позициями.
  2. Если снаряд достигает targetPosition, то мы проверяем, существует ли ещё target.
  3. Мы получаем компонент HealthBar цели и уменьшаем её здоровье на величину damage снаряда.
  4. Если здоровье врага снижается до нуля, то мы уничтожаем его, воспроизводим звуковой эффект и вознаграждаем игрока за меткость.

Сохраните файл и вернитесь в Unity.

Делаем крупные снаряды


Разве не будет здорово, если монстр начнёт на высоких уровнях стрелять снарядами побольше? К счастью, это легко реализовать.

Перетащите игровой объект Bullet1 из Hierarchy во вкладку Project, чтобы создать префаб снаряда. Удалите из сцены исходный объект — нам он больше не понадобится.

Дважды продублируйте префаб Bullet1. Назовите копии Bullet2 и Bullet3.

Выберите Bullet2. В Inspector задайте полю Sprite компонента Sprite Renderer значение Images/Objects/Bullet2. Так мы сделаем Bullet2 немного больше, чем Bullet1.

Повторите процедуру, чтобы изменить спрайт префаба Bullet3 на Images/Objects/Bullet3.

Далее в Bullet Behavior мы настроим величину урона, наносимого снарядами.

Выберите во вкладке Project префаб Bullet1. В Inspector вы увидите Bullet Behavior (Script), в котором можно присвоить Damage значение 10 для Bullet1, 15 для Bullet2 и 20 для Bullet3 — или любые другие значения, какие вам понравятся.

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


Префабы снарядов — размер увеличивается с уровнем

Изменение уровня снарядов


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

Откройте в IDE MonsterData.cs и добавьте в MonsterLevel следующие переменные:

public GameObject bullet;
public float fireRate;

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

Выберите в Project Browser префаб Monster. В Inspector разверните Levels в компоненте Monster Data (Script). Задайте для Fire Rate каждого из элементов значение 1. Затем задайте параметру Bullet у Element 0, 1 и 2 значения Bullet1, Bullet2 и Bullet3.

Уровни монстров должны быть настроены следующим образом:


Снаряды убивают врагов? Да! Откроем огонь!

Открываем огонь


Откройте в IDE ShootEnemies.cs и добавьте следующие переменные:

private float lastShotTime;
private MonsterData monsterData;

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

Зададим значения этих полей в Start():

lastShotTime = Time.time;
monsterData = gameObject.GetComponentInChildren<MonsterData>();

Здесь мы присваиваем lastShotTime значение текущего времени и получаем доступ к компоненту MonsterData этого объекта.

Добавим следующий метод, чтобы реализовать стрельбу:

void Shoot(Collider2D target)
{
  GameObject bulletPrefab = monsterData.CurrentLevel.bullet;
  // 1 
  Vector3 startPosition = gameObject.transform.position;
  Vector3 targetPosition = target.transform.position;
  startPosition.z = bulletPrefab.transform.position.z;
  targetPosition.z = bulletPrefab.transform.position.z;

  // 2 
  GameObject newBullet = (GameObject)Instantiate (bulletPrefab);
  newBullet.transform.position = startPosition;
  BulletBehavior bulletComp = newBullet.GetComponent<BulletBehavior>();
  bulletComp.target = target.gameObject;
  bulletComp.startPosition = startPosition;
  bulletComp.targetPosition = targetPosition;

  // 3 
  Animator animator = 
      monsterData.CurrentLevel.visualization.GetComponent<Animator>();
  animator.SetTrigger("fireShot");
  AudioSource audioSource = gameObject.GetComponent<AudioSource>();
  audioSource.PlayOneShot(audioSource.clip);
}

  1. Получаем начальную и целевую позиции пули. Задаём позицию z равной z bulletPrefab. Раньше мы задали позицию префаба снаряда по z таким образом, чтобы снаряд появлялся под стреляющим монстром, но над врагами.
  2. Создаём экземпляр нового снаряда с помощью bulletPrefab соответствующего MonsterLevel. Назначаем startPosition и targetPosition снаряда.
  3. Делаем игру интереснее: когда монстр стреляет, запускаем анимацию стрельбы и воспроизводим звук лазера.

Собираем всё вместе


Настало время соединить всё. Определим цель и сделаем так, чтобы монстр смотрел на неё.


В скрипте ShootEnemies.cs добавим в Update() такой код:

GameObject target = null;
// 1
float minimalEnemyDistance = float.MaxValue;
foreach (GameObject enemy in enemiesInRange)
{
  float distanceToGoal = enemy.GetComponent<MoveEnemy>().DistanceToGoal();
  if (distanceToGoal < minimalEnemyDistance)
  {
    target = enemy;
    minimalEnemyDistance = distanceToGoal;
  }
}
// 2
if (target != null)
{
  if (Time.time - lastShotTime > monsterData.CurrentLevel.fireRate)
  {
    Shoot(target.GetComponent<Collider2D>());
    lastShotTime = Time.time;
  }
  // 3
  Vector3 direction = gameObject.transform.position - target.transform.position;
  gameObject.transform.rotation = Quaternion.AngleAxis(
      Mathf.Atan2 (direction.y, direction.x) * 180 / Mathf.PI,
      new Vector3 (0, 0, 1));
}

Рассмотрим этот код шаг за шагом.

  1. Определяем цель монстра. Начинаем с максимально возможного расстояния в minimalEnemyDistance. Обходим в цикле всех врагов в радиусе действия и делаем врага новой целью, если его расстояние до печеньки меньше текущего наименьшего.
  2. Вызываем Shoot, если прошедшее время больше частоты стрельбы монстра и задаём lastShotTime значение текущего времени.
  3. Вычисляем угол поворота между монстром и его целью. Выполняем поворот монстра на этот угол. Теперь он всегда будет смотреть на цель.

Сохраните файл и запустите игру в Unity. Монстры начнёт отчаянно защищать печенье. Мы наконец-то закончили!

Куда двигаться дальше


Готовый проект можно скачать отсюда.

Мы проделали в этом туториале большую работу и теперь у нас есть отличная игра.

Вот несколько идей для дальнейшего развития проекта:

  • Больше типов врагов и монстров
  • Различные маршруты врагов
  • Разные уровни игры

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

Интересные мысли о создании хитовой игры в жанре tower defense можно найти в этом интервью.
  • +22
  • 5,6k
  • 1
Поделиться публикацией

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

Комментарии 1
    –1
    В начале было много скриншотов, а в конце вообще ни одного. Как в результате выглядит игра? Интересно в оригинале так же или это только в переводе так?

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

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