Как создать игру Tower Defense в Unity — часть 2
Во второй части уроков, посвященных созданию игры Tower Defense в Unity вам предстоит добавить в локацию нескольких стреляющих монстров.
Добро пожаловать во вторую часть урока «Как создать игру Tower Defense в Unity. В Unity». К концу предыдущего занятия вы научились размещать и улучшать монстров, а также создали одного вражеского юнита.
Однако ваш противник все еще не имеет понятия, в какую сторону ему идти! В этой части вы добавите вражеские волны и вооружите своих монстров, чтобы они могли защищать свои владения от атак.
Введение
Находясь в Unity откройте законченный проект из первой части этого учебного цикла или, если вы только что присоединились, загрузите начальный проект тут и откройте файл TowerDefense-Part2-Starter. Далее откройте GameScene из папки Scenes.
Траектория врагов
В конце последнего урока противник мог перемещаться по дороге, но, похоже, понятия не имел, в какую сторону ему нужно идти.
Откройте MoveEnemy.cs в вашей IDE и добавьте следующий метод, чтобы это исправить:
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 определяет положение противника таким образом, чтобы он всегда смотрел вперед, вот таким образом:
- Вычисляется текущее направление движения, вычитая позицию текущей путевой точки из позиции следующей путевой точки.
- Используется Mathf.Atan2 для определения угла, на который указывает newDirection, используя нулевые точки отправления. Умножение результата на 180/Mathf.PI преобразует угол в градусы.
- Извлекается дочерний элемент Sprite и поворачивает его на несколько градусов по оси Z. Обратите внимание, что вы поворачиваете дочерний элемент вместо родительского, поэтому индикатор работоспособности, который вы скоро добавите, остается горизонтальным.
В Update () замените комментарий /TODO: Rotate into move direction на RotateIntoMoveDirection:
RotateIntoMoveDirection();
Сохраните файл и переключившись на Unity запустите сцену. Теперь ваш монстр точно знает, куда он идет:
Вам не нравится, что сейчас существует всего один единственный враг и он не впечатляет своей мощностью? Тогда приступайте к созданию целых волн разнообразных монстров, как бывает во всех в классических играх Tower Defense.
Сообщение игроку
Прежде чем вы создадите орды врагов, необходимо сообщить игроку о предстоящем наступлении. Кроме того, почему бы не отобразить номер текущей волны в верхней части экрана?
Некоторым объектам GameObject требуется волновая информация, поэтому вам нужно добавить ее в компонент GameManagerBehavior в GameManager.
Откройте GameManagerBehavior.cs в вашей IDE и добавьте эти две переменные:
public Text waveLabel; public GameObject[] nextWaveLabels;
WaveLabel будет хранить ссылку на значение волны в верхнем правом углу экрана. nextWaveLabels сохранит два объекта GameObject, которые при объединении создают анимацию, которая будет отображаться в начале новой волны, как показано ниже:
Сохраните файл и переключитесь на Unity. Выберите GameManager в Hierarchy и нажмите на маленький кружок справа от Wave Label. В новом диалоговом окне Select Text выберите WaveLabel на вкладке Scene.
Теперь установите значение Size of Next Wave Labels равным 2. Затем присвойте элементу 0 значение NextWaveBottomLabel, а элементу 1 значение NextWaveTopLabel.
Если игрок проиграет, он не должен видеть сообщение о следующей волне. Для этого вернитесь в GameManager Behavior.cs в вашей IDE и добавьте еще одну переменную:
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); } }
Таким образом вы добавляете в волну новое значение.
Затем вы проверяете, что игра не окончена. Если это так, вы перебираете все метки в nextWaveLabels — эти метки имеют компонент Animator. Для запуска анимации в Animator вам нужно установить триггер nextWave. Наконец, вы устанавливаете текст waveLabel в значение wave + 1.
В Start () установите значение этого свойства:
Wave = 0;
Вы начинаете считать с волны номер 0.
Сохраните файл, затем запустите сцену в Unity. Смотрите, отсчет атак начинается с 1, как и должно быть:
Создание новых врагов
Это звучит очевидно, но вам нужно создать больше врагов, чтобы создать волновые атаки — сейчас вы пока не можете этого сделать. Кроме того, вы не должны вызывать следующую волну после того, как текущая волна уничтожена — по крайней мере, на данный момент.
Таким образом, игра должна уметь распознавать, есть ли враги на сцене, используя для этой цели специальные теги.
Создание Enemy Tag
Выберите префаб Enemy в Project Browser и в верхней части меню Inspector щелкните на раскрывающийся список «Tag» и выберите «Add Tag».
Создайте тег с именем Enemy.
Снова выберите префаб Enemy и установите для него соответствующий тег в окне Inspector.
Определение вражеских волн
Теперь вам нужно определить волну врагов. Откройте Spawn Enemy.cs в вашей IDE и добавьте следующее значение перед Spawn Enemy:
[System.Serializable] public class Wave { public GameObject enemyPrefab; public float spawnInterval = 2; public int maxEnemies = 20; }
Волна содержит вражеский префаб, основу для создания всех врагов, spawnInterval определяет время в секундах между набегами врагов в волне, а maxEnemies содержит информацию о количестве врагов, появившихся в этой волне.
Этот класс называется Serializable, что означает, что вы можете изменять его значения в Инспекторе.
Добавьте следующие переменные в класс SpawnEnemy:
public Wave[] waves; public int timeBetweenWaves = 5; private GameManagerBehavior gameManager; private float lastSpawnTime; private int enemiesSpawned = 0;
Это установит несколько переменных для появления врагов. В дальнейшем вам нужно определить различные волны в игре в зависимости от уровня и соответственно увеличить количество врагов в каждой волне.
Игрокам нужен перерыв между нападениями чтобы иметь возможность улучшить своих юнитов и перевести дух, поэтому вам нужно установить 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); }
Вот что означает каждый шаг:
- Определяется индекс текущей волны и идет проверка, является ли она последней.
- Если это так, идет подсчет, сколько времени прошло с момента появления последнего врага и не пора ли появиться новому. Здесь может быть несколько вариантов. Если это первый враг в волне, проверяется, больше ли timeInterval, чем timeBetweenWaves. В противном случае проверяется, больше ли timeInterval, чем spawnInterval этой волны.
- Если необходимо, создается новый экземпляр вражеского префаба. Также увеличивается количество появившихся врагов.
- Идет проверка количество врагов на экране. Если их нет, и это был последний враг, то запускается следующая волна. Также игрок получает золото в виде награды за успешное прохождение уровня.
- После уничтожения последней волной врагов в игре запускается анимация победы.
Определение интервалов появления
Сохраните файл, переключитесь на Unity и выберите Road в Hierarchy. В Inspector установите Size of Waves = 4.
На данный момент вам нужно установить Enemy Prefab на Enemy для всех четырех элементов. Настройте поля Spawn Interval и Max Enemies следующим образом:
Элемент 0: Интервал появления: 2.5, макс. враги: 5
Элемент 1: Интервал появления: 2, макс. враги: 10
Элемент 2: Интервал появления: 2, макс. враги: 15
Элемент 3: Интервал появления: 1, максимальное количество врагов: 5
Окончательная настройка должна выглядеть как на скриншоте ниже:
Попробуйте запустить игру с этими настройками. Смотрите – на данный момент существует существенный баг, о котором мы поговорим далее.
Добавление разных врагов
Выберите Prefabs ⇒ Enemy2 в Inspector и добавьте в него скрипт MoveEnemy. Установите его скорость на 3, а его метку на врага.
Индикатор здоровья игрока
Несмотря на то, что орды жуков нападают на печенье, база игрока все еще не получает никакого урона. При каждом проникновении вражеского юнита на базу должен наноситься урон.
Откройте GameManagerBehavior.cs в вашей IDE и добавьте две переменные:
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); } } } }
Эти настройки будут управлять здоровьем игрока:
- Если уменьшается здоровье игрока, используется компонент CameraShake, для создания эффекта дрожания.
- Обновляются переменные и значение здоровья в верхнем левом углу экрана.
- Если здоровье падает до 0, и игра еще не закончена, для gameOver устанавливается значение true и запускается анимация GameOver.
- Удаляется один из монстров на базе.
Инициализируйте здоровье в Start ():
Health = 5;
Таким образом, в начале здоровье базы игрока будет равняться пяти.
Используя это свойство, вы можете обновлять здоровье игрока каждый раз, когда ошибка достигает cookie. Сохраните этот файл и затем переключитесь на MoveEnemy.cs в IDE.
Обновление параметров здоровья
Чтобы обновить здоровье игрока, найдите в Update () строчку // TODO: deduct health и замените ее следующим кодом:
GameManagerBehavior gameManager = GameObject.Find("GameManager").GetComponent<GameManagerBehavior>(); gameManager.Health -= 1;
Таким образом будет получена информация о GameManagerBehavior и произойдут изменения индикатора здоровья. После этого сохраните файл и переключитесь на Unity.
Выберите GameManager в Hierarchy и установите для HealthIndicator значение HealthLabel. Разверните Cookie в Cookie и перетащите HealthIndicator в массив HealthLabel в GameManager.
Попробуйте запустить игру и подождите, пока жуки доберутся до печенья. Ничего не делайте, пока не проиграете.
Атака монстров
Чтобы ваши монстры могли отбиваться от наступающих врагов вам нужно сделать несколько вещей:
- Создать панель здоровья, чтобы игрок знал, какие враги сильные и слабые
- Настроить обнаружение врагов в радиусе действия монстра
- Определить в какого врага стрелять нужно
- Создать патроны
Здоровье врагов
Вы будете использовать два изображения для отображения панели здоровья, одно для темного фона и зеленую полосу, которую вы масштабируете.
Перетащите Prefabs\Enemy в сцену из Project Browser. Затем перетащите Images ⇒ Objects ⇒ HealthBarBackground на Enemy в Hierarchy, чтобы добавить его в качестве дочернего элемента. В окне Inspector установите координаты Position для HealthBarBackground на (0, 1, -4).
Затем выберите Images ⇒ Objects ⇒ HealthBar в Project Browser и убедитесь, что Pivot имеет значение Left. Затем добавьте его как дочерний элемент Enemy в Hierarchy установите координаты Position на (-0.63, 1, -5), а X Scale = 125. Добавьте новый сценарий C# с именем HealthBar к игровому объекту HealthBar.
Снова выберите в Hierarchy значение Enemy и убедитесь, что его координаты находятся в (20, 0, 0). Нажмите Apply в верхней части окна Inspector, чтобы сохранить все ваши изменения как часть префаба. Теперь вы можете удалить Enemy из Hierarchy.
Повторите эти шаги, чтобы самостоятельно добавить панель здоровья в Prefabs\Enemy2.
Настройка длинны панели здоровья
Откройте HealthBar.cs в IDE и добавьте следующие переменные:
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, чтобы проверить как отображается шкала здоровья над вражескими юнитами.
Во время игры разверните один из объектов Enemy (Clone) в окне Hierarchy и выберите его дочерний элемент HealthBar. Измените значение Current Health и проверьте, изменяется ли при этом шкала здоровья.
Отслеживать врагов в радиусе обзора
Теперь ваши монстры должны знать, на каких врагов они должны нападать. Выберите Prefabs\Monster в Project Browser и добавьте в окне Inspector новый 2D-компонент Circle Collider. Установите значение Radius = 2,5 — это определит дальность стрельбы монстров.
Также не забудьте активировать Trigger, чтобы жуки проходили через область, а не врезались в нее. Сверху окна Inspector, установите Monster‘s Layer в положение Ignore Raycast. И нажмите Yes, изменив дочерние элементы в диалоговом окне.
В Project Browser выберите Prefabs\Enemy и добавьте новый двухмерный компонент Rigidbody установив Body Type на Kinematic. Теперь добавьте Circle Collider 2D со значением Radius = 1. Повторите эти действия для Prefabs\Enemy 2.
Вам нужно подготовить еще одну вещь: скрипт, который уведомляет монстров, когда враг уничтожен. Для этого создайте новый сценарий C# с именем EnemyDestructionDelegate и добавьте его в префабы Enemy и Enemy2.
Откройте EnemyDestructionDelegate.cs в IDE и добавьте следующие данные:
public delegate void EnemyDelegate (GameObject enemy); public EnemyDelegate enemyDelegate; Добавьте еще один метод: void OnDestroy() { if (enemyDelegate != null) { enemyDelegate(gameObject); } }
После того, как противник будет уничтожен, Unity будет автоматически вызывать этот метод автоматически. Сохраните файл и вернитесь в Unity.
Уничтожение врагов
Теперь ваши монстры могут обнаруживать врагов в радиусе действия. Добавьте новый сценарий C# в префаб Monster и назовите его ShootEnemies.
Откройте ShootEnemies.cs в IDE и добавьте следующую строчку:
using System.Collections.Generic;
Добавьте переменную, чтобы отслеживать всех врагов в пределах диапазона:
public List<GameObject> enemiesInRange;
Инициализируйте данное поле в Start ():
enemiesInRange = new List<GameObject>();
Сначала врагов в радиусе действия нет, поэтому вы создаете пустой список.
Добавьте этот код в скрипт:
// 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; } }
В OnEnemyDestroy происходит удаление врага, который находится в диапазоне ваших юнитов. Когда враг ходит по курку вокруг вашего монстра, вызывается OnTriggerEnter2D.
Затем вражеский юнит добавляется в список врагов InRange и OnEnemyDestroy помещается в EnemyDestructionDelegate.
В OnTriggerExit2D враг удаляется из списка объектов.
Сохраните файл и запустите игру в Unity. Чтобы проверить, вступили ли в силу внесенные вами изменения посмотрите уничтожат ли ваши юниты врага.
Определение цели
Откройте MoveEnemy.cs в IDE и добавьте следующий метод:
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; }
Этот код вычисляет длину траектории, которая еще не пройдена противником. Сохраните файл и вернитесь в Unity, чтобы настроить стрельбу.
Вооружение дружественных юнитов
Перетащите Images/Objects/Bullet1 из Project Browser на сцену. Установите для позиции Z значение -2. Добавьте новый скрипт C# с именем BulletBehavior и внесите в него следующие переменные:
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; Присвойте значения этим переменным в Start (): startTime = Time.time; distance = Vector2.Distance (startPosition, targetPosition); GameObject gm = GameObject.Find("GameManager"); gameManager = gm.GetComponent<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); }
Сохраните файл и вернитесь в Unity.
Использование больших пуль
Согласитесь, было бы здорово, если бы ваш монстр стрелял большими пулями на более сложных уровнях. К счастью, это довольно легко реализовать.
Перетащите игровой объект Bullet1 из окна Hierarchy на вкладку «Project» и удалите оригинальный объект со сцены. Дублируйте префаб Bullet1 дважды. Назовите копии Bullet2 и Bullet3.
Выберите Bullet2 В окне Inspector и установите для поля «Sprite» значение «Images/Objects/Bullet2». Повторите эту процедуру, чтобы установить спрайт префаба Bullet3 в Images/Objects/Bullet3.
Теперь вам нужно определит, какой урон наносят пули в режиме Bullet Behavior. Выберите префаб Bullet1 на вкладке Project. и установите 10 урона для Bullet1, 15 для Bullet2 и 20 для Bullet3.
Назначение каждого вида пуль
Назначайте разные пули разным уровням монстров, чтобы более сильные монстры быстрее уничтожали врагов.
Откройте MonsterData.cs в IDE и добавьте эти переменные в MonsterLevel:
public GameObject bullet; public float fireRate;
Теперь выберите префаб Monster в Project Browser., а в окне Inspector разверните меню «Levels» в компоненте «Monster Data». Установите Fire Rate на 1 для каждого из элементов. Затем установите Bullet для элементов 0, 1 и 2 — Bullet1, Bullet2 и Bullet3 соответственно.
Уровни монстров должны быть настроены, как показано на изображении ниже:
Открытый огонь
Откройте ShootEnemies.cs в IDE и добавьте несколько переменных:
private float lastShotTime; private MonsterData monsterData;
Присвойте значения этим полям в Start ():
lastShotTime = Time.time; monsterData = gameObject.GetComponentInChildren<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); }
Соединение действий
Время связать все вместе. Определите цель и заставьте своего монстра наблюдать за ней.
Теперь в 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)); }
Сохраните файл и попробуйте запустить вашу игру в Unity. Теперь все ваши монстры будут рьяно защищать печеньки и вашу работу можно считать завершенной!
Что делать дальше?
Вы можете скачать готовый проект здесь.
Теперь у вас есть интересная игра и вы можете использовать полученные знания для ее улучшения. Например, попробуйте создать больше путей по которым перемещаются враги или смоделируйте больше типов врагов.
В этом интервью вы можете найти интересные идеи о том, как сделать игру Tower Defense еще интереснее.